diff --git a/public/r/Shuffle-JS-CSS.json b/public/r/Shuffle-JS-CSS.json index b1c1ddddc..200427264 100644 --- a/public/r/Shuffle-JS-CSS.json +++ b/public/r/Shuffle-JS-CSS.json @@ -14,7 +14,7 @@ { "type": "registry:component", "path": "Shuffle.jsx", - "content": "import React, { useRef, useEffect, useState, useMemo } from 'react';\nimport { gsap } from 'gsap';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { SplitText as GSAPSplitText } from 'gsap/SplitText';\nimport { useGSAP } from '@gsap/react';\nimport './Shuffle.css';\n\ngsap.registerPlugin(ScrollTrigger, GSAPSplitText, useGSAP);\n\nconst Shuffle = ({\n text,\n className = '',\n style = {},\n shuffleDirection = 'right',\n duration = 0.35,\n maxDelay = 0,\n ease = 'power3.out',\n threshold = 0.1,\n rootMargin = '-100px',\n tag = 'p',\n textAlign = 'center',\n onShuffleComplete,\n shuffleTimes = 1,\n animationMode = 'evenodd',\n loop = false,\n loopDelay = 0,\n stagger = 0.03,\n scrambleCharset = '',\n colorFrom,\n colorTo,\n triggerOnce = true,\n respectReducedMotion = true,\n triggerOnHover = true\n}) => {\n const ref = useRef(null);\n const [fontsLoaded, setFontsLoaded] = useState(false);\n const [ready, setReady] = useState(false);\n\n const splitRef = useRef(null);\n const wrappersRef = useRef([]);\n const tlRef = useRef(null);\n const playingRef = useRef(false);\n const hoverHandlerRef = useRef(null);\n\n useEffect(() => {\n if ('fonts' in document) {\n if (document.fonts.status === 'loaded') setFontsLoaded(true);\n else document.fonts.ready.then(() => setFontsLoaded(true));\n } else setFontsLoaded(true);\n }, []);\n\n const scrollTriggerStart = useMemo(() => {\n const startPct = (1 - threshold) * 100;\n const mm = /^(-?\\d+(?:\\.\\d+)?)(px|em|rem|%)?$/.exec(rootMargin || '');\n const mv = mm ? parseFloat(mm[1]) : 0;\n const mu = mm ? mm[2] || 'px' : 'px';\n const sign = mv === 0 ? '' : mv < 0 ? `-=${Math.abs(mv)}${mu}` : `+=${mv}${mu}`;\n return `top ${startPct}%${sign}`;\n }, [threshold, rootMargin]);\n\n useGSAP(\n () => {\n if (!ref.current || !text || !fontsLoaded) return;\n if (respectReducedMotion && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {\n setReady(true);\n onShuffleComplete?.();\n return;\n }\n\n const el = ref.current;\n\n const start = scrollTriggerStart;\n\n const removeHover = () => {\n if (hoverHandlerRef.current && ref.current) {\n ref.current.removeEventListener('mouseenter', hoverHandlerRef.current);\n hoverHandlerRef.current = null;\n }\n };\n\n const teardown = () => {\n if (tlRef.current) {\n tlRef.current.kill();\n tlRef.current = null;\n }\n if (wrappersRef.current.length) {\n wrappersRef.current.forEach(wrap => {\n const inner = wrap.firstElementChild;\n const orig = inner?.querySelector('[data-orig=\"1\"]');\n if (orig && wrap.parentNode) wrap.parentNode.replaceChild(orig, wrap);\n });\n wrappersRef.current = [];\n }\n try {\n splitRef.current?.revert();\n } catch {\n /* noop */\n }\n splitRef.current = null;\n playingRef.current = false;\n };\n\n const build = () => {\n teardown();\n\n splitRef.current = new GSAPSplitText(el, {\n type: 'chars',\n charsClass: 'shuffle-char',\n wordsClass: 'shuffle-word',\n linesClass: 'shuffle-line',\n smartWrap: true,\n reduceWhiteSpace: false\n });\n\n const chars = splitRef.current.chars || [];\n wrappersRef.current = [];\n\n const rolls = Math.max(1, Math.floor(shuffleTimes));\n const rand = set => set.charAt(Math.floor(Math.random() * set.length)) || '';\n\n chars.forEach(ch => {\n const parent = ch.parentElement;\n if (!parent) return;\n\n const w = ch.getBoundingClientRect().width;\n const h = ch.getBoundingClientRect().height;\n if (!w) return;\n\n const wrap = document.createElement('span');\n Object.assign(wrap.style, {\n display: 'inline-block',\n overflow: 'hidden',\n width: w + 'px',\n height: shuffleDirection === 'up' || shuffleDirection === 'down' ? h + 'px' : 'auto',\n verticalAlign: 'bottom'\n });\n\n const inner = document.createElement('span');\n Object.assign(inner.style, {\n display: 'inline-block',\n whiteSpace: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'normal' : 'nowrap',\n willChange: 'transform'\n });\n\n parent.insertBefore(wrap, ch);\n wrap.appendChild(inner);\n\n const firstOrig = ch.cloneNode(true);\n Object.assign(firstOrig.style, {\n display: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block',\n width: w + 'px',\n textAlign: 'center'\n });\n\n ch.setAttribute('data-orig', '1');\n Object.assign(ch.style, {\n display: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block',\n width: w + 'px',\n textAlign: 'center'\n });\n\n inner.appendChild(firstOrig);\n for (let k = 0; k < rolls; k++) {\n const c = ch.cloneNode(true);\n if (scrambleCharset) c.textContent = rand(scrambleCharset);\n Object.assign(c.style, {\n display: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block',\n width: w + 'px',\n textAlign: 'center'\n });\n inner.appendChild(c);\n }\n inner.appendChild(ch);\n\n const steps = rolls + 1;\n\n if (shuffleDirection === 'right' || shuffleDirection === 'down') {\n const firstCopy = inner.firstElementChild;\n const real = inner.lastElementChild;\n if (real) inner.insertBefore(real, inner.firstChild);\n if (firstCopy) inner.appendChild(firstCopy);\n }\n\n let startX = 0;\n let finalX = 0;\n let startY = 0;\n let finalY = 0;\n\n if (shuffleDirection === 'right') {\n startX = -steps * w;\n finalX = 0;\n } else if (shuffleDirection === 'left') {\n startX = 0;\n finalX = -steps * w;\n } else if (shuffleDirection === 'down') {\n startY = -steps * h;\n finalY = 0;\n } else if (shuffleDirection === 'up') {\n startY = 0;\n finalY = -steps * h;\n }\n\n if (shuffleDirection === 'left' || shuffleDirection === 'right') {\n gsap.set(inner, { x: startX, y: 0, force3D: true });\n inner.setAttribute('data-start-x', String(startX));\n inner.setAttribute('data-final-x', String(finalX));\n } else {\n gsap.set(inner, { x: 0, y: startY, force3D: true });\n inner.setAttribute('data-start-y', String(startY));\n inner.setAttribute('data-final-y', String(finalY));\n }\n\n if (colorFrom) inner.style.color = colorFrom;\n wrappersRef.current.push(wrap);\n });\n };\n\n const inners = () => wrappersRef.current.map(w => w.firstElementChild);\n\n const randomizeScrambles = () => {\n if (!scrambleCharset) return;\n wrappersRef.current.forEach(w => {\n const strip = w.firstElementChild;\n if (!strip) return;\n const kids = Array.from(strip.children);\n for (let i = 1; i < kids.length - 1; i++) {\n kids[i].textContent = scrambleCharset.charAt(Math.floor(Math.random() * scrambleCharset.length));\n }\n });\n };\n\n const cleanupToStill = () => {\n wrappersRef.current.forEach(w => {\n const strip = w.firstElementChild;\n if (!strip) return;\n const real = strip.querySelector('[data-orig=\"1\"]');\n if (!real) return;\n strip.replaceChildren(real);\n strip.style.transform = 'none';\n strip.style.willChange = 'auto';\n });\n };\n\n const play = () => {\n const strips = inners();\n if (!strips.length) return;\n\n playingRef.current = true;\n const isVertical = shuffleDirection === 'up' || shuffleDirection === 'down';\n\n const tl = gsap.timeline({\n smoothChildTiming: true,\n repeat: loop ? -1 : 0,\n repeatDelay: loop ? loopDelay : 0,\n onRepeat: () => {\n if (scrambleCharset) randomizeScrambles();\n if (isVertical) {\n gsap.set(strips, { y: (i, t) => parseFloat(t.getAttribute('data-start-y') || '0') });\n } else {\n gsap.set(strips, { x: (i, t) => parseFloat(t.getAttribute('data-start-x') || '0') });\n }\n onShuffleComplete?.();\n },\n onComplete: () => {\n playingRef.current = false;\n if (!loop) {\n cleanupToStill();\n if (colorTo) gsap.set(strips, { color: colorTo });\n onShuffleComplete?.();\n armHover();\n }\n }\n });\n\n const addTween = (targets, at) => {\n const vars = {\n duration,\n ease,\n force3D: true,\n stagger: animationMode === 'evenodd' ? stagger : 0\n };\n if (isVertical) {\n vars.y = (i, t) => parseFloat(t.getAttribute('data-final-y') || '0');\n } else {\n vars.x = (i, t) => parseFloat(t.getAttribute('data-final-x') || '0');\n }\n\n tl.to(targets, vars, at);\n\n if (colorFrom && colorTo) {\n tl.to(targets, { color: colorTo, duration, ease }, at);\n }\n };\n\n if (animationMode === 'evenodd') {\n const odd = strips.filter((_, i) => i % 2 === 1);\n const even = strips.filter((_, i) => i % 2 === 0);\n const oddTotal = duration + Math.max(0, odd.length - 1) * stagger;\n const evenStart = odd.length ? oddTotal * 0.7 : 0;\n if (odd.length) addTween(odd, 0);\n if (even.length) addTween(even, evenStart);\n } else {\n strips.forEach(strip => {\n const d = Math.random() * maxDelay;\n const vars = {\n duration,\n ease,\n force3D: true\n };\n if (isVertical) {\n vars.y = parseFloat(strip.getAttribute('data-final-y') || '0');\n } else {\n vars.x = parseFloat(strip.getAttribute('data-final-x') || '0');\n }\n tl.to(strip, vars, d);\n if (colorFrom && colorTo) tl.fromTo(strip, { color: colorFrom }, { color: colorTo, duration, ease }, d);\n });\n }\n\n tlRef.current = tl;\n };\n\n const armHover = () => {\n if (!triggerOnHover || !ref.current) return;\n removeHover();\n const handler = () => {\n if (playingRef.current) return;\n build();\n if (scrambleCharset) randomizeScrambles();\n play();\n };\n hoverHandlerRef.current = handler;\n ref.current.addEventListener('mouseenter', handler);\n };\n\n const create = () => {\n build();\n if (scrambleCharset) randomizeScrambles();\n play();\n armHover();\n setReady(true);\n };\n\n const st = ScrollTrigger.create({\n trigger: el,\n start,\n once: triggerOnce,\n onEnter: create\n });\n\n return () => {\n st.kill();\n removeHover();\n teardown();\n setReady(false);\n };\n },\n {\n dependencies: [\n text,\n duration,\n maxDelay,\n ease,\n scrollTriggerStart,\n fontsLoaded,\n shuffleDirection,\n shuffleTimes,\n animationMode,\n loop,\n loopDelay,\n stagger,\n scrambleCharset,\n colorFrom,\n colorTo,\n triggerOnce,\n respectReducedMotion,\n triggerOnHover,\n onShuffleComplete\n ],\n scope: ref\n }\n );\n\n const commonStyle = useMemo(() => ({ textAlign, ...style }), [textAlign, style]);\n\n const classes = useMemo(() => `shuffle-parent ${ready ? 'is-ready' : ''} ${className}`, [ready, className]);\n\n const Tag = tag || 'p';\n return React.createElement(Tag, { ref, className: classes, style: commonStyle }, text);\n};\n\nexport default Shuffle;\n" + "content": "import React, { useRef, useEffect, useState, useMemo } from 'react';\nimport { gsap } from 'gsap';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { SplitText as GSAPSplitText } from 'gsap/SplitText';\nimport { useGSAP } from '@gsap/react';\nimport './Shuffle.css';\n\ngsap.registerPlugin(ScrollTrigger, GSAPSplitText, useGSAP);\n\nconst Shuffle = ({\n text,\n className = '',\n style = {},\n shuffleDirection = 'right',\n duration = 0.35,\n maxDelay = 0,\n ease = 'power3.out',\n threshold = 0.1,\n rootMargin = '-100px',\n tag = 'p',\n textAlign = 'center',\n onShuffleComplete,\n shuffleTimes = 1,\n animationMode = 'evenodd',\n loop = false,\n loopDelay = 0,\n stagger = 0.03,\n scrambleCharset = '',\n colorFrom,\n colorTo,\n triggerOnce = true,\n respectReducedMotion = true,\n triggerOnHover = true\n}) => {\n const ref = useRef(null);\n const [fontsLoaded, setFontsLoaded] = useState(false);\n const [ready, setReady] = useState(false);\n\n const splitRef = useRef(null);\n const wrappersRef = useRef([]);\n const tlRef = useRef(null);\n const playingRef = useRef(false);\n const hoverHandlerRef = useRef(null);\n\n useEffect(() => {\n if ('fonts' in document) {\n if (document.fonts.status === 'loaded') setFontsLoaded(true);\n else document.fonts.ready.then(() => setFontsLoaded(true));\n } else setFontsLoaded(true);\n }, []);\n\n const scrollTriggerStart = useMemo(() => {\n const startPct = (1 - threshold) * 100;\n const mm = /^(-?\\d+(?:\\.\\d+)?)(px|em|rem|%)?$/.exec(rootMargin || '');\n const mv = mm ? parseFloat(mm[1]) : 0;\n const mu = mm ? mm[2] || 'px' : 'px';\n const sign = mv === 0 ? '' : mv < 0 ? `-=${Math.abs(mv)}${mu}` : `+=${mv}${mu}`;\n return `top ${startPct}%${sign}`;\n }, [threshold, rootMargin]);\n\n useGSAP(\n () => {\n if (!ref.current || !text || !fontsLoaded) return;\n if (respectReducedMotion && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {\n setReady(true);\n onShuffleComplete?.();\n return;\n }\n\n const el = ref.current;\n\n const start = scrollTriggerStart;\n\n const removeHover = () => {\n if (hoverHandlerRef.current && ref.current) {\n ref.current.removeEventListener('mouseenter', hoverHandlerRef.current);\n hoverHandlerRef.current = null;\n }\n };\n\n const teardown = () => {\n if (tlRef.current) {\n tlRef.current.kill();\n tlRef.current = null;\n }\n if (wrappersRef.current.length) {\n wrappersRef.current.forEach(wrap => {\n const inner = wrap.firstElementChild;\n const orig = inner?.querySelector('[data-orig=\"1\"]');\n if (orig && wrap.parentNode) wrap.parentNode.replaceChild(orig, wrap);\n });\n wrappersRef.current = [];\n }\n try {\n splitRef.current?.revert();\n } catch {\n /* noop */\n }\n splitRef.current = null;\n playingRef.current = false;\n };\n\n const build = () => {\n teardown();\n\n splitRef.current = new GSAPSplitText(el, {\n type: 'chars',\n charsClass: 'shuffle-char',\n wordsClass: 'shuffle-word',\n linesClass: 'shuffle-line',\n smartWrap: true,\n reduceWhiteSpace: false\n });\n\n const chars = splitRef.current.chars || [];\n wrappersRef.current = [];\n\n const rolls = Math.max(1, Math.floor(shuffleTimes));\n const rand = set => set.charAt(Math.floor(Math.random() * set.length)) || '';\n const isVertical = shuffleDirection === 'up' || shuffleDirection === 'down';\n const metricsContext = isVertical ? document.createElement('canvas').getContext('2d') : null;\n\n const measureVerticalCell = (node, lineBoxHeight) => {\n const computed = window.getComputedStyle(node);\n let fontHeight = 0;\n\n if (metricsContext) {\n metricsContext.font = [\n computed.fontStyle,\n computed.fontVariant,\n computed.fontWeight,\n computed.fontSize,\n computed.fontFamily\n ].join(' ');\n\n const sample = `${node.textContent || 'M'}${scrambleCharset}`;\n const metrics = metricsContext.measureText(sample);\n const ascent = metrics.fontBoundingBoxAscent;\n const descent = metrics.fontBoundingBoxDescent;\n if (Number.isFinite(ascent) && Number.isFinite(descent)) fontHeight = ascent + descent;\n }\n\n if (!fontHeight) {\n const probe = node.cloneNode(true);\n probe.textContent = `${node.textContent || 'M'}${scrambleCharset}`;\n Object.assign(probe.style, {\n position: 'absolute',\n visibility: 'hidden',\n pointerEvents: 'none',\n width: 'auto',\n height: 'auto',\n whiteSpace: 'nowrap',\n lineHeight: 'normal',\n fontFamily: computed.fontFamily,\n fontSize: computed.fontSize,\n fontStyle: computed.fontStyle,\n fontVariant: computed.fontVariant,\n fontWeight: computed.fontWeight,\n fontStretch: computed.fontStretch\n });\n document.body.appendChild(probe);\n fontHeight = probe.getBoundingClientRect().height;\n probe.remove();\n }\n\n const overflow = Math.max(0, Math.ceil(fontHeight - lineBoxHeight));\n const padTop = Math.floor(overflow / 2);\n return { cellHeight: lineBoxHeight + overflow, padTop, padBottom: overflow - padTop };\n };\n\n chars.forEach(ch => {\n const parent = ch.parentElement;\n if (!parent) return;\n\n const w = ch.getBoundingClientRect().width;\n const h = ch.getBoundingClientRect().height;\n if (!w) return;\n\n const { cellHeight, padTop, padBottom } = isVertical\n ? measureVerticalCell(ch, h)\n : { cellHeight: h, padTop: 0, padBottom: 0 };\n\n const wrap = document.createElement('span');\n Object.assign(wrap.style, {\n display: 'inline-block',\n overflow: 'hidden',\n width: w + 'px',\n height: isVertical ? cellHeight + 'px' : 'auto',\n marginTop: isVertical ? -padTop + 'px' : '0',\n marginBottom: isVertical ? -padBottom + 'px' : '0',\n verticalAlign: 'bottom'\n });\n\n const inner = document.createElement('span');\n Object.assign(inner.style, {\n display: 'inline-block',\n whiteSpace: isVertical ? 'normal' : 'nowrap',\n willChange: 'transform'\n });\n\n parent.insertBefore(wrap, ch);\n wrap.appendChild(inner);\n\n const firstOrig = ch.cloneNode(true);\n Object.assign(firstOrig.style, {\n display: isVertical ? 'flex' : 'inline-block',\n alignItems: isVertical ? 'center' : '',\n justifyContent: isVertical ? 'center' : '',\n height: isVertical ? cellHeight + 'px' : '',\n lineHeight: isVertical ? h + 'px' : '',\n width: w + 'px',\n textAlign: 'center'\n });\n\n ch.setAttribute('data-orig', '1');\n Object.assign(ch.style, {\n display: isVertical ? 'flex' : 'inline-block',\n alignItems: isVertical ? 'center' : '',\n justifyContent: isVertical ? 'center' : '',\n height: isVertical ? cellHeight + 'px' : '',\n lineHeight: isVertical ? h + 'px' : '',\n width: w + 'px',\n textAlign: 'center'\n });\n\n inner.appendChild(firstOrig);\n for (let k = 0; k < rolls; k++) {\n const c = ch.cloneNode(true);\n if (scrambleCharset) c.textContent = rand(scrambleCharset);\n Object.assign(c.style, {\n display: isVertical ? 'flex' : 'inline-block',\n alignItems: isVertical ? 'center' : '',\n justifyContent: isVertical ? 'center' : '',\n height: isVertical ? cellHeight + 'px' : '',\n lineHeight: isVertical ? h + 'px' : '',\n width: w + 'px',\n textAlign: 'center'\n });\n inner.appendChild(c);\n }\n inner.appendChild(ch);\n\n const steps = rolls + 1;\n\n if (shuffleDirection === 'right' || shuffleDirection === 'down') {\n const firstCopy = inner.firstElementChild;\n const real = inner.lastElementChild;\n if (real) inner.insertBefore(real, inner.firstChild);\n if (firstCopy) inner.appendChild(firstCopy);\n }\n\n let startX = 0;\n let finalX = 0;\n let startY = 0;\n let finalY = 0;\n\n if (shuffleDirection === 'right') {\n startX = -steps * w;\n finalX = 0;\n } else if (shuffleDirection === 'left') {\n startX = 0;\n finalX = -steps * w;\n } else if (shuffleDirection === 'down') {\n startY = -steps * cellHeight;\n finalY = 0;\n } else if (shuffleDirection === 'up') {\n startY = 0;\n finalY = -steps * cellHeight;\n }\n\n if (shuffleDirection === 'left' || shuffleDirection === 'right') {\n gsap.set(inner, { x: startX, y: 0, force3D: true });\n inner.setAttribute('data-start-x', String(startX));\n inner.setAttribute('data-final-x', String(finalX));\n } else {\n gsap.set(inner, { x: 0, y: startY, force3D: true });\n inner.setAttribute('data-start-y', String(startY));\n inner.setAttribute('data-final-y', String(finalY));\n }\n\n if (colorFrom) inner.style.color = colorFrom;\n wrappersRef.current.push(wrap);\n });\n };\n\n const inners = () => wrappersRef.current.map(w => w.firstElementChild);\n\n const randomizeScrambles = () => {\n if (!scrambleCharset) return;\n wrappersRef.current.forEach(w => {\n const strip = w.firstElementChild;\n if (!strip) return;\n const kids = Array.from(strip.children);\n for (let i = 1; i < kids.length - 1; i++) {\n kids[i].textContent = scrambleCharset.charAt(Math.floor(Math.random() * scrambleCharset.length));\n }\n });\n };\n\n const cleanupToStill = () => {\n wrappersRef.current.forEach(w => {\n const strip = w.firstElementChild;\n if (!strip) return;\n const real = strip.querySelector('[data-orig=\"1\"]');\n if (!real) return;\n strip.replaceChildren(real);\n strip.style.transform = 'none';\n strip.style.willChange = 'auto';\n });\n };\n\n const play = () => {\n const strips = inners();\n if (!strips.length) return;\n\n playingRef.current = true;\n const isVertical = shuffleDirection === 'up' || shuffleDirection === 'down';\n\n const tl = gsap.timeline({\n smoothChildTiming: true,\n repeat: loop ? -1 : 0,\n repeatDelay: loop ? loopDelay : 0,\n onRepeat: () => {\n if (scrambleCharset) randomizeScrambles();\n if (isVertical) {\n gsap.set(strips, { y: (i, t) => parseFloat(t.getAttribute('data-start-y') || '0') });\n } else {\n gsap.set(strips, { x: (i, t) => parseFloat(t.getAttribute('data-start-x') || '0') });\n }\n onShuffleComplete?.();\n },\n onComplete: () => {\n playingRef.current = false;\n if (!loop) {\n cleanupToStill();\n if (colorTo) gsap.set(strips, { color: colorTo });\n onShuffleComplete?.();\n armHover();\n }\n }\n });\n\n const addTween = (targets, at) => {\n const vars = {\n duration,\n ease,\n force3D: true,\n stagger: animationMode === 'evenodd' ? stagger : 0\n };\n if (isVertical) {\n vars.y = (i, t) => parseFloat(t.getAttribute('data-final-y') || '0');\n } else {\n vars.x = (i, t) => parseFloat(t.getAttribute('data-final-x') || '0');\n }\n\n tl.to(targets, vars, at);\n\n if (colorFrom && colorTo) {\n tl.to(targets, { color: colorTo, duration, ease }, at);\n }\n };\n\n if (animationMode === 'evenodd') {\n const odd = strips.filter((_, i) => i % 2 === 1);\n const even = strips.filter((_, i) => i % 2 === 0);\n const oddTotal = duration + Math.max(0, odd.length - 1) * stagger;\n const evenStart = odd.length ? oddTotal * 0.7 : 0;\n if (odd.length) addTween(odd, 0);\n if (even.length) addTween(even, evenStart);\n } else {\n strips.forEach(strip => {\n const d = Math.random() * maxDelay;\n const vars = {\n duration,\n ease,\n force3D: true\n };\n if (isVertical) {\n vars.y = parseFloat(strip.getAttribute('data-final-y') || '0');\n } else {\n vars.x = parseFloat(strip.getAttribute('data-final-x') || '0');\n }\n tl.to(strip, vars, d);\n if (colorFrom && colorTo) tl.fromTo(strip, { color: colorFrom }, { color: colorTo, duration, ease }, d);\n });\n }\n\n tlRef.current = tl;\n };\n\n const armHover = () => {\n if (!triggerOnHover || !ref.current) return;\n removeHover();\n const handler = () => {\n if (playingRef.current) return;\n build();\n if (scrambleCharset) randomizeScrambles();\n play();\n };\n hoverHandlerRef.current = handler;\n ref.current.addEventListener('mouseenter', handler);\n };\n\n const create = () => {\n build();\n if (scrambleCharset) randomizeScrambles();\n play();\n armHover();\n setReady(true);\n };\n\n const st = ScrollTrigger.create({\n trigger: el,\n start,\n once: triggerOnce,\n onEnter: create\n });\n\n return () => {\n st.kill();\n removeHover();\n teardown();\n setReady(false);\n };\n },\n {\n dependencies: [\n text,\n duration,\n maxDelay,\n ease,\n scrollTriggerStart,\n fontsLoaded,\n shuffleDirection,\n shuffleTimes,\n animationMode,\n loop,\n loopDelay,\n stagger,\n scrambleCharset,\n colorFrom,\n colorTo,\n triggerOnce,\n respectReducedMotion,\n triggerOnHover,\n onShuffleComplete\n ],\n scope: ref\n }\n );\n\n const commonStyle = useMemo(() => ({ textAlign, ...style }), [textAlign, style]);\n\n const classes = useMemo(() => `shuffle-parent ${ready ? 'is-ready' : ''} ${className}`, [ready, className]);\n\n const Tag = tag || 'p';\n return React.createElement(Tag, { ref, className: classes, style: commonStyle }, text);\n};\n\nexport default Shuffle;\n" } ], "registryDependencies": [], diff --git a/public/r/Shuffle-JS-TW.json b/public/r/Shuffle-JS-TW.json index 18c018ff0..e816bee2f 100644 --- a/public/r/Shuffle-JS-TW.json +++ b/public/r/Shuffle-JS-TW.json @@ -8,7 +8,7 @@ { "type": "registry:component", "path": "Shuffle/Shuffle.jsx", - "content": "import React, { useRef, useEffect, useState, useMemo } from 'react';\nimport { gsap } from 'gsap';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { SplitText as GSAPSplitText } from 'gsap/SplitText';\nimport { useGSAP } from '@gsap/react';\n\ngsap.registerPlugin(ScrollTrigger, GSAPSplitText, useGSAP);\n\nconst Shuffle = ({\n text,\n className = '',\n style = {},\n shuffleDirection = 'right',\n duration = 0.35,\n maxDelay = 0,\n ease = 'power3.out',\n threshold = 0.1,\n rootMargin = '-100px',\n tag = 'p',\n textAlign = 'center',\n onShuffleComplete,\n shuffleTimes = 1,\n animationMode = 'evenodd',\n loop = false,\n loopDelay = 0,\n stagger = 0.03,\n scrambleCharset = '',\n colorFrom,\n colorTo,\n triggerOnce = true,\n respectReducedMotion = true,\n triggerOnHover = true\n}) => {\n const ref = useRef(null);\n const [fontsLoaded, setFontsLoaded] = useState(false);\n const [ready, setReady] = useState(false);\n\n const splitRef = useRef(null);\n const wrappersRef = useRef([]);\n const tlRef = useRef(null);\n const playingRef = useRef(false);\n const hoverHandlerRef = useRef(null);\n\n const userHasFont = useMemo(\n () => (style && style.fontFamily) || (className && /font[-[]/i.test(className)),\n [style, className]\n );\n\n const scrollTriggerStart = useMemo(() => {\n const startPct = (1 - threshold) * 100;\n const mm = /^(-?\\d+(?:\\.\\d+)?)(px|em|rem|%)?$/.exec(rootMargin || '');\n const mv = mm ? parseFloat(mm[1]) : 0;\n const mu = mm ? mm[2] || 'px' : 'px';\n const sign = mv === 0 ? '' : mv < 0 ? `-=${Math.abs(mv)}${mu}` : `+=${mv}${mu}`;\n return `top ${startPct}%${sign}`;\n }, [threshold, rootMargin]);\n\n useEffect(() => {\n if ('fonts' in document) {\n if (document.fonts.status === 'loaded') setFontsLoaded(true);\n else document.fonts.ready.then(() => setFontsLoaded(true));\n } else setFontsLoaded(true);\n }, []);\n\n useGSAP(\n () => {\n if (!ref.current || !text || !fontsLoaded) return;\n\n if (respectReducedMotion && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {\n onShuffleComplete?.();\n return;\n }\n\n const el = ref.current;\n\n let computedFont = '';\n if (userHasFont) {\n computedFont = style.fontFamily || getComputedStyle(el).fontFamily || '';\n } else {\n computedFont = `'Press Start 2P', sans-serif`;\n }\n\n const start = scrollTriggerStart;\n\n const removeHover = () => {\n if (hoverHandlerRef.current && ref.current) {\n ref.current.removeEventListener('mouseenter', hoverHandlerRef.current);\n hoverHandlerRef.current = null;\n }\n };\n\n const teardown = () => {\n if (tlRef.current) {\n tlRef.current.kill();\n tlRef.current = null;\n }\n if (wrappersRef.current.length) {\n wrappersRef.current.forEach(wrap => {\n const inner = wrap.firstElementChild;\n const orig = inner?.querySelector('[data-orig=\"1\"]');\n if (orig && wrap.parentNode) wrap.parentNode.replaceChild(orig, wrap);\n });\n wrappersRef.current = [];\n }\n try {\n splitRef.current?.revert();\n } catch {\n /* noop */\n }\n splitRef.current = null;\n playingRef.current = false;\n };\n\n const build = () => {\n teardown();\n\n splitRef.current = new GSAPSplitText(el, {\n type: 'chars',\n charsClass: 'shuffle-char',\n wordsClass: 'shuffle-word',\n linesClass: 'shuffle-line',\n smartWrap: true,\n reduceWhiteSpace: false\n });\n\n const chars = splitRef.current.chars || [];\n wrappersRef.current = [];\n\n const rolls = Math.max(1, Math.floor(shuffleTimes));\n const rand = set => set.charAt(Math.floor(Math.random() * set.length)) || '';\n\n chars.forEach(ch => {\n const parent = ch.parentElement;\n if (!parent) return;\n\n const w = ch.getBoundingClientRect().width;\n const h = ch.getBoundingClientRect().height;\n if (!w) return;\n\n const wrap = document.createElement('span');\n wrap.className = 'inline-block overflow-hidden text-left';\n Object.assign(wrap.style, {\n width: w + 'px',\n height: shuffleDirection === 'up' || shuffleDirection === 'down' ? h + 'px' : 'auto',\n verticalAlign: 'bottom'\n });\n\n const inner = document.createElement('span');\n inner.className =\n 'inline-block will-change-transform origin-left transform-gpu ' +\n (shuffleDirection === 'up' || shuffleDirection === 'down' ? 'whitespace-normal' : 'whitespace-nowrap');\n\n parent.insertBefore(wrap, ch);\n wrap.appendChild(inner);\n\n const firstOrig = ch.cloneNode(true);\n firstOrig.className =\n 'text-left ' + (shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block');\n Object.assign(firstOrig.style, { width: w + 'px', fontFamily: computedFont });\n\n ch.setAttribute('data-orig', '1');\n ch.className =\n 'text-left ' + (shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block');\n Object.assign(ch.style, { width: w + 'px', fontFamily: computedFont });\n\n inner.appendChild(firstOrig);\n for (let k = 0; k < rolls; k++) {\n const c = ch.cloneNode(true);\n if (scrambleCharset) c.textContent = rand(scrambleCharset);\n c.className =\n 'text-left ' + (shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block');\n Object.assign(c.style, { width: w + 'px', fontFamily: computedFont });\n inner.appendChild(c);\n }\n inner.appendChild(ch);\n\n const steps = rolls + 1;\n\n if (shuffleDirection === 'right' || shuffleDirection === 'down') {\n const firstCopy = inner.firstElementChild;\n const real = inner.lastElementChild;\n if (real) inner.insertBefore(real, inner.firstChild);\n if (firstCopy) inner.appendChild(firstCopy);\n }\n\n let startX = 0;\n let finalX = 0;\n let startY = 0;\n let finalY = 0;\n\n if (shuffleDirection === 'right') {\n startX = -steps * w;\n finalX = 0;\n } else if (shuffleDirection === 'left') {\n startX = 0;\n finalX = -steps * w;\n } else if (shuffleDirection === 'down') {\n startY = -steps * h;\n finalY = 0;\n } else if (shuffleDirection === 'up') {\n startY = 0;\n finalY = -steps * h;\n }\n\n if (shuffleDirection === 'left' || shuffleDirection === 'right') {\n gsap.set(inner, { x: startX, y: 0, force3D: true });\n inner.setAttribute('data-start-x', String(startX));\n inner.setAttribute('data-final-x', String(finalX));\n } else {\n gsap.set(inner, { x: 0, y: startY, force3D: true });\n inner.setAttribute('data-start-y', String(startY));\n inner.setAttribute('data-final-y', String(finalY));\n }\n\n if (colorFrom) inner.style.color = colorFrom;\n wrappersRef.current.push(wrap);\n });\n };\n\n const inners = () => wrappersRef.current.map(w => w.firstElementChild);\n\n const randomizeScrambles = () => {\n if (!scrambleCharset) return;\n wrappersRef.current.forEach(w => {\n const strip = w.firstElementChild;\n if (!strip) return;\n const kids = Array.from(strip.children);\n for (let i = 1; i < kids.length - 1; i++) {\n kids[i].textContent = scrambleCharset.charAt(Math.floor(Math.random() * scrambleCharset.length));\n }\n });\n };\n\n const cleanupToStill = () => {\n wrappersRef.current.forEach(w => {\n const strip = w.firstElementChild;\n if (!strip) return;\n const real = strip.querySelector('[data-orig=\"1\"]');\n if (!real) return;\n strip.replaceChildren(real);\n strip.style.transform = 'none';\n strip.style.willChange = 'auto';\n });\n };\n\n const play = () => {\n const strips = inners();\n if (!strips.length) return;\n\n playingRef.current = true;\n const isVertical = shuffleDirection === 'up' || shuffleDirection === 'down';\n\n const tl = gsap.timeline({\n smoothChildTiming: true,\n repeat: loop ? -1 : 0,\n repeatDelay: loop ? loopDelay : 0,\n onRepeat: () => {\n if (scrambleCharset) randomizeScrambles();\n if (isVertical) {\n gsap.set(strips, { y: (i, t) => parseFloat(t.getAttribute('data-start-y') || '0') });\n } else {\n gsap.set(strips, { x: (i, t) => parseFloat(t.getAttribute('data-start-x') || '0') });\n }\n onShuffleComplete?.();\n },\n onComplete: () => {\n playingRef.current = false;\n if (!loop) {\n cleanupToStill();\n if (colorTo) gsap.set(strips, { color: colorTo });\n onShuffleComplete?.();\n armHover();\n }\n }\n });\n\n const addTween = (targets, at) => {\n const vars = {\n duration,\n ease,\n force3D: true,\n stagger: animationMode === 'evenodd' ? stagger : 0\n };\n if (isVertical) {\n vars.y = (i, t) => parseFloat(t.getAttribute('data-final-y') || '0');\n } else {\n vars.x = (i, t) => parseFloat(t.getAttribute('data-final-x') || '0');\n }\n\n tl.to(targets, vars, at);\n\n if (colorFrom && colorTo) tl.to(targets, { color: colorTo, duration, ease }, at);\n };\n\n if (animationMode === 'evenodd') {\n const odd = strips.filter((_, i) => i % 2 === 1);\n const even = strips.filter((_, i) => i % 2 === 0);\n const oddTotal = duration + Math.max(0, odd.length - 1) * stagger;\n const evenStart = odd.length ? oddTotal * 0.7 : 0;\n if (odd.length) addTween(odd, 0);\n if (even.length) addTween(even, evenStart);\n } else {\n strips.forEach(strip => {\n const d = Math.random() * maxDelay;\n const vars = {\n duration,\n ease,\n force3D: true\n };\n if (isVertical) {\n vars.y = parseFloat(strip.getAttribute('data-final-y') || '0');\n } else {\n vars.x = parseFloat(strip.getAttribute('data-final-x') || '0');\n }\n tl.to(strip, vars, d);\n if (colorFrom && colorTo) tl.fromTo(strip, { color: colorFrom }, { color: colorTo, duration, ease }, d);\n });\n }\n\n tlRef.current = tl;\n };\n\n const armHover = () => {\n if (!triggerOnHover || !ref.current) return;\n removeHover();\n const handler = () => {\n if (playingRef.current) return;\n build();\n if (scrambleCharset) randomizeScrambles();\n play();\n };\n hoverHandlerRef.current = handler;\n ref.current.addEventListener('mouseenter', handler);\n };\n\n const create = () => {\n build();\n if (scrambleCharset) randomizeScrambles();\n play();\n armHover();\n setReady(true);\n };\n\n const st = ScrollTrigger.create({ trigger: el, start, once: triggerOnce, onEnter: create });\n\n return () => {\n st.kill();\n removeHover();\n teardown();\n setReady(false);\n };\n },\n {\n dependencies: [\n text,\n duration,\n maxDelay,\n ease,\n scrollTriggerStart,\n fontsLoaded,\n shuffleDirection,\n shuffleTimes,\n animationMode,\n loop,\n loopDelay,\n stagger,\n scrambleCharset,\n colorFrom,\n colorTo,\n triggerOnce,\n respectReducedMotion,\n triggerOnHover,\n onShuffleComplete,\n userHasFont\n ],\n scope: ref\n }\n );\n\n const baseTw = 'inline-block whitespace-normal break-words will-change-transform uppercase text-[4rem] leading-none';\n const classes = useMemo(\n () => `${baseTw} ${ready ? 'visible' : 'invisible'} ${className}`.trim(),\n [baseTw, ready, className]\n );\n const Tag = tag || 'p';\n const commonStyle = useMemo(() => ({ textAlign, ...style }), [textAlign, style]);\n\n return React.createElement(Tag, { ref: ref, className: classes, style: commonStyle }, text);\n};\n\nexport default Shuffle;\n" + "content": "import React, { useRef, useEffect, useState, useMemo } from 'react';\nimport { gsap } from 'gsap';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { SplitText as GSAPSplitText } from 'gsap/SplitText';\nimport { useGSAP } from '@gsap/react';\n\ngsap.registerPlugin(ScrollTrigger, GSAPSplitText, useGSAP);\n\nconst Shuffle = ({\n text,\n className = '',\n style = {},\n shuffleDirection = 'right',\n duration = 0.35,\n maxDelay = 0,\n ease = 'power3.out',\n threshold = 0.1,\n rootMargin = '-100px',\n tag = 'p',\n textAlign = 'center',\n onShuffleComplete,\n shuffleTimes = 1,\n animationMode = 'evenodd',\n loop = false,\n loopDelay = 0,\n stagger = 0.03,\n scrambleCharset = '',\n colorFrom,\n colorTo,\n triggerOnce = true,\n respectReducedMotion = true,\n triggerOnHover = true\n}) => {\n const ref = useRef(null);\n const [fontsLoaded, setFontsLoaded] = useState(false);\n const [ready, setReady] = useState(false);\n\n const splitRef = useRef(null);\n const wrappersRef = useRef([]);\n const tlRef = useRef(null);\n const playingRef = useRef(false);\n const hoverHandlerRef = useRef(null);\n\n const userHasFont = useMemo(\n () => (style && style.fontFamily) || (className && /font[-[]/i.test(className)),\n [style, className]\n );\n\n const scrollTriggerStart = useMemo(() => {\n const startPct = (1 - threshold) * 100;\n const mm = /^(-?\\d+(?:\\.\\d+)?)(px|em|rem|%)?$/.exec(rootMargin || '');\n const mv = mm ? parseFloat(mm[1]) : 0;\n const mu = mm ? mm[2] || 'px' : 'px';\n const sign = mv === 0 ? '' : mv < 0 ? `-=${Math.abs(mv)}${mu}` : `+=${mv}${mu}`;\n return `top ${startPct}%${sign}`;\n }, [threshold, rootMargin]);\n\n useEffect(() => {\n if ('fonts' in document) {\n if (document.fonts.status === 'loaded') setFontsLoaded(true);\n else document.fonts.ready.then(() => setFontsLoaded(true));\n } else setFontsLoaded(true);\n }, []);\n\n useGSAP(\n () => {\n if (!ref.current || !text || !fontsLoaded) return;\n\n if (respectReducedMotion && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {\n onShuffleComplete?.();\n return;\n }\n\n const el = ref.current;\n\n let computedFont = '';\n if (userHasFont) {\n computedFont = style.fontFamily || getComputedStyle(el).fontFamily || '';\n } else {\n computedFont = `'Press Start 2P', sans-serif`;\n }\n\n const start = scrollTriggerStart;\n\n const removeHover = () => {\n if (hoverHandlerRef.current && ref.current) {\n ref.current.removeEventListener('mouseenter', hoverHandlerRef.current);\n hoverHandlerRef.current = null;\n }\n };\n\n const teardown = () => {\n if (tlRef.current) {\n tlRef.current.kill();\n tlRef.current = null;\n }\n if (wrappersRef.current.length) {\n wrappersRef.current.forEach(wrap => {\n const inner = wrap.firstElementChild;\n const orig = inner?.querySelector('[data-orig=\"1\"]');\n if (orig && wrap.parentNode) wrap.parentNode.replaceChild(orig, wrap);\n });\n wrappersRef.current = [];\n }\n try {\n splitRef.current?.revert();\n } catch {\n /* noop */\n }\n splitRef.current = null;\n playingRef.current = false;\n };\n\n const build = () => {\n teardown();\n\n splitRef.current = new GSAPSplitText(el, {\n type: 'chars',\n charsClass: 'shuffle-char',\n wordsClass: 'shuffle-word',\n linesClass: 'shuffle-line',\n smartWrap: true,\n reduceWhiteSpace: false\n });\n\n const chars = splitRef.current.chars || [];\n wrappersRef.current = [];\n\n const rolls = Math.max(1, Math.floor(shuffleTimes));\n const rand = set => set.charAt(Math.floor(Math.random() * set.length)) || '';\n const isVertical = shuffleDirection === 'up' || shuffleDirection === 'down';\n const metricsContext = isVertical ? document.createElement('canvas').getContext('2d') : null;\n\n const measureVerticalCell = (node, lineBoxHeight) => {\n const computed = window.getComputedStyle(node);\n let fontHeight = 0;\n\n if (metricsContext) {\n metricsContext.font = [\n computed.fontStyle,\n computed.fontVariant,\n computed.fontWeight,\n computed.fontSize,\n computed.fontFamily\n ].join(' ');\n\n const sample = `${node.textContent || 'M'}${scrambleCharset}`;\n const metrics = metricsContext.measureText(sample);\n const ascent = metrics.fontBoundingBoxAscent;\n const descent = metrics.fontBoundingBoxDescent;\n if (Number.isFinite(ascent) && Number.isFinite(descent)) fontHeight = ascent + descent;\n }\n\n if (!fontHeight) {\n const probe = node.cloneNode(true);\n probe.textContent = `${node.textContent || 'M'}${scrambleCharset}`;\n Object.assign(probe.style, {\n position: 'absolute',\n visibility: 'hidden',\n pointerEvents: 'none',\n width: 'auto',\n height: 'auto',\n whiteSpace: 'nowrap',\n lineHeight: 'normal',\n fontFamily: computed.fontFamily,\n fontSize: computed.fontSize,\n fontStyle: computed.fontStyle,\n fontVariant: computed.fontVariant,\n fontWeight: computed.fontWeight,\n fontStretch: computed.fontStretch\n });\n document.body.appendChild(probe);\n fontHeight = probe.getBoundingClientRect().height;\n probe.remove();\n }\n\n const overflow = Math.max(0, Math.ceil(fontHeight - lineBoxHeight));\n const padTop = Math.floor(overflow / 2);\n return { cellHeight: lineBoxHeight + overflow, padTop, padBottom: overflow - padTop };\n };\n\n chars.forEach(ch => {\n const parent = ch.parentElement;\n if (!parent) return;\n\n const w = ch.getBoundingClientRect().width;\n const h = ch.getBoundingClientRect().height;\n if (!w) return;\n\n const { cellHeight, padTop, padBottom } = isVertical\n ? measureVerticalCell(ch, h)\n : { cellHeight: h, padTop: 0, padBottom: 0 };\n\n const wrap = document.createElement('span');\n wrap.className = 'inline-block overflow-hidden text-left';\n Object.assign(wrap.style, {\n width: w + 'px',\n height: isVertical ? cellHeight + 'px' : 'auto',\n marginTop: isVertical ? -padTop + 'px' : '0',\n marginBottom: isVertical ? -padBottom + 'px' : '0',\n verticalAlign: 'bottom'\n });\n\n const inner = document.createElement('span');\n inner.className =\n 'inline-block will-change-transform origin-left transform-gpu ' +\n (isVertical ? 'whitespace-normal' : 'whitespace-nowrap');\n\n parent.insertBefore(wrap, ch);\n wrap.appendChild(inner);\n\n const firstOrig = ch.cloneNode(true);\n firstOrig.className = 'text-left ' + (isVertical ? 'flex' : 'inline-block');\n Object.assign(firstOrig.style, {\n alignItems: isVertical ? 'center' : '',\n justifyContent: isVertical ? 'center' : '',\n height: isVertical ? cellHeight + 'px' : '',\n lineHeight: isVertical ? h + 'px' : '',\n width: w + 'px',\n fontFamily: computedFont\n });\n\n ch.setAttribute('data-orig', '1');\n ch.className = 'text-left ' + (isVertical ? 'flex' : 'inline-block');\n Object.assign(ch.style, {\n alignItems: isVertical ? 'center' : '',\n justifyContent: isVertical ? 'center' : '',\n height: isVertical ? cellHeight + 'px' : '',\n lineHeight: isVertical ? h + 'px' : '',\n width: w + 'px',\n fontFamily: computedFont\n });\n\n inner.appendChild(firstOrig);\n for (let k = 0; k < rolls; k++) {\n const c = ch.cloneNode(true);\n if (scrambleCharset) c.textContent = rand(scrambleCharset);\n c.className = 'text-left ' + (isVertical ? 'flex' : 'inline-block');\n Object.assign(c.style, {\n alignItems: isVertical ? 'center' : '',\n justifyContent: isVertical ? 'center' : '',\n height: isVertical ? cellHeight + 'px' : '',\n lineHeight: isVertical ? h + 'px' : '',\n width: w + 'px',\n fontFamily: computedFont\n });\n inner.appendChild(c);\n }\n inner.appendChild(ch);\n\n const steps = rolls + 1;\n\n if (shuffleDirection === 'right' || shuffleDirection === 'down') {\n const firstCopy = inner.firstElementChild;\n const real = inner.lastElementChild;\n if (real) inner.insertBefore(real, inner.firstChild);\n if (firstCopy) inner.appendChild(firstCopy);\n }\n\n let startX = 0;\n let finalX = 0;\n let startY = 0;\n let finalY = 0;\n\n if (shuffleDirection === 'right') {\n startX = -steps * w;\n finalX = 0;\n } else if (shuffleDirection === 'left') {\n startX = 0;\n finalX = -steps * w;\n } else if (shuffleDirection === 'down') {\n startY = -steps * cellHeight;\n finalY = 0;\n } else if (shuffleDirection === 'up') {\n startY = 0;\n finalY = -steps * cellHeight;\n }\n\n if (shuffleDirection === 'left' || shuffleDirection === 'right') {\n gsap.set(inner, { x: startX, y: 0, force3D: true });\n inner.setAttribute('data-start-x', String(startX));\n inner.setAttribute('data-final-x', String(finalX));\n } else {\n gsap.set(inner, { x: 0, y: startY, force3D: true });\n inner.setAttribute('data-start-y', String(startY));\n inner.setAttribute('data-final-y', String(finalY));\n }\n\n if (colorFrom) inner.style.color = colorFrom;\n wrappersRef.current.push(wrap);\n });\n };\n\n const inners = () => wrappersRef.current.map(w => w.firstElementChild);\n\n const randomizeScrambles = () => {\n if (!scrambleCharset) return;\n wrappersRef.current.forEach(w => {\n const strip = w.firstElementChild;\n if (!strip) return;\n const kids = Array.from(strip.children);\n for (let i = 1; i < kids.length - 1; i++) {\n kids[i].textContent = scrambleCharset.charAt(Math.floor(Math.random() * scrambleCharset.length));\n }\n });\n };\n\n const cleanupToStill = () => {\n wrappersRef.current.forEach(w => {\n const strip = w.firstElementChild;\n if (!strip) return;\n const real = strip.querySelector('[data-orig=\"1\"]');\n if (!real) return;\n strip.replaceChildren(real);\n strip.style.transform = 'none';\n strip.style.willChange = 'auto';\n });\n };\n\n const play = () => {\n const strips = inners();\n if (!strips.length) return;\n\n playingRef.current = true;\n const isVertical = shuffleDirection === 'up' || shuffleDirection === 'down';\n\n const tl = gsap.timeline({\n smoothChildTiming: true,\n repeat: loop ? -1 : 0,\n repeatDelay: loop ? loopDelay : 0,\n onRepeat: () => {\n if (scrambleCharset) randomizeScrambles();\n if (isVertical) {\n gsap.set(strips, { y: (i, t) => parseFloat(t.getAttribute('data-start-y') || '0') });\n } else {\n gsap.set(strips, { x: (i, t) => parseFloat(t.getAttribute('data-start-x') || '0') });\n }\n onShuffleComplete?.();\n },\n onComplete: () => {\n playingRef.current = false;\n if (!loop) {\n cleanupToStill();\n if (colorTo) gsap.set(strips, { color: colorTo });\n onShuffleComplete?.();\n armHover();\n }\n }\n });\n\n const addTween = (targets, at) => {\n const vars = {\n duration,\n ease,\n force3D: true,\n stagger: animationMode === 'evenodd' ? stagger : 0\n };\n if (isVertical) {\n vars.y = (i, t) => parseFloat(t.getAttribute('data-final-y') || '0');\n } else {\n vars.x = (i, t) => parseFloat(t.getAttribute('data-final-x') || '0');\n }\n\n tl.to(targets, vars, at);\n\n if (colorFrom && colorTo) tl.to(targets, { color: colorTo, duration, ease }, at);\n };\n\n if (animationMode === 'evenodd') {\n const odd = strips.filter((_, i) => i % 2 === 1);\n const even = strips.filter((_, i) => i % 2 === 0);\n const oddTotal = duration + Math.max(0, odd.length - 1) * stagger;\n const evenStart = odd.length ? oddTotal * 0.7 : 0;\n if (odd.length) addTween(odd, 0);\n if (even.length) addTween(even, evenStart);\n } else {\n strips.forEach(strip => {\n const d = Math.random() * maxDelay;\n const vars = {\n duration,\n ease,\n force3D: true\n };\n if (isVertical) {\n vars.y = parseFloat(strip.getAttribute('data-final-y') || '0');\n } else {\n vars.x = parseFloat(strip.getAttribute('data-final-x') || '0');\n }\n tl.to(strip, vars, d);\n if (colorFrom && colorTo) tl.fromTo(strip, { color: colorFrom }, { color: colorTo, duration, ease }, d);\n });\n }\n\n tlRef.current = tl;\n };\n\n const armHover = () => {\n if (!triggerOnHover || !ref.current) return;\n removeHover();\n const handler = () => {\n if (playingRef.current) return;\n build();\n if (scrambleCharset) randomizeScrambles();\n play();\n };\n hoverHandlerRef.current = handler;\n ref.current.addEventListener('mouseenter', handler);\n };\n\n const create = () => {\n build();\n if (scrambleCharset) randomizeScrambles();\n play();\n armHover();\n setReady(true);\n };\n\n const st = ScrollTrigger.create({ trigger: el, start, once: triggerOnce, onEnter: create });\n\n return () => {\n st.kill();\n removeHover();\n teardown();\n setReady(false);\n };\n },\n {\n dependencies: [\n text,\n duration,\n maxDelay,\n ease,\n scrollTriggerStart,\n fontsLoaded,\n shuffleDirection,\n shuffleTimes,\n animationMode,\n loop,\n loopDelay,\n stagger,\n scrambleCharset,\n colorFrom,\n colorTo,\n triggerOnce,\n respectReducedMotion,\n triggerOnHover,\n onShuffleComplete,\n userHasFont\n ],\n scope: ref\n }\n );\n\n const baseTw = 'inline-block whitespace-normal break-words will-change-transform uppercase text-[4rem] leading-none';\n const classes = useMemo(\n () => `${baseTw} ${ready ? 'visible' : 'invisible'} ${className}`.trim(),\n [baseTw, ready, className]\n );\n const Tag = tag || 'p';\n const commonStyle = useMemo(() => ({ textAlign, ...style }), [textAlign, style]);\n\n return React.createElement(Tag, { ref: ref, className: classes, style: commonStyle }, text);\n};\n\nexport default Shuffle;\n" } ], "registryDependencies": [], diff --git a/public/r/Shuffle-TS-CSS.json b/public/r/Shuffle-TS-CSS.json index 92fed8083..f8af89988 100644 --- a/public/r/Shuffle-TS-CSS.json +++ b/public/r/Shuffle-TS-CSS.json @@ -14,7 +14,7 @@ { "type": "registry:component", "path": "Shuffle.tsx", - "content": "import React, { useRef, useEffect, useState, useMemo } from 'react';\nimport { gsap } from 'gsap';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { SplitText as GSAPSplitText } from 'gsap/SplitText';\nimport { useGSAP } from '@gsap/react';\nimport './Shuffle.css';\n\ngsap.registerPlugin(ScrollTrigger, GSAPSplitText, useGSAP);\n\nexport interface ShuffleProps {\n text: string;\n className?: string;\n style?: React.CSSProperties;\n shuffleDirection?: 'left' | 'right' | 'up' | 'down';\n duration?: number;\n maxDelay?: number;\n ease?: string | ((t: number) => number);\n threshold?: number;\n rootMargin?: string;\n tag?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span';\n textAlign?: React.CSSProperties['textAlign'];\n onShuffleComplete?: () => void;\n shuffleTimes?: number;\n animationMode?: 'random' | 'evenodd';\n loop?: boolean;\n loopDelay?: number;\n stagger?: number;\n scrambleCharset?: string;\n colorFrom?: string;\n colorTo?: string;\n triggerOnce?: boolean;\n respectReducedMotion?: boolean;\n triggerOnHover?: boolean;\n}\n\nconst Shuffle: React.FC = ({\n text,\n className = '',\n style = {},\n shuffleDirection = 'right',\n duration = 0.35,\n maxDelay = 0,\n ease = 'power3.out',\n threshold = 0.1,\n rootMargin = '-100px',\n tag = 'p',\n textAlign = 'center',\n onShuffleComplete,\n shuffleTimes = 1,\n animationMode = 'evenodd',\n loop = false,\n loopDelay = 0,\n stagger = 0.03,\n scrambleCharset = '',\n colorFrom,\n colorTo,\n triggerOnce = true,\n respectReducedMotion = true,\n triggerOnHover = true\n}) => {\n const ref = useRef(null);\n const [fontsLoaded, setFontsLoaded] = useState(false);\n const [ready, setReady] = useState(false);\n\n const splitRef = useRef(null);\n const wrappersRef = useRef([]);\n const tlRef = useRef(null);\n const playingRef = useRef(false);\n const hoverHandlerRef = useRef<((e: Event) => void) | null>(null);\n\n useEffect(() => {\n if ('fonts' in document) {\n if (document.fonts.status === 'loaded') setFontsLoaded(true);\n else document.fonts.ready.then(() => setFontsLoaded(true));\n } else setFontsLoaded(true);\n }, []);\n\n const scrollTriggerStart = useMemo(() => {\n const startPct = (1 - threshold) * 100;\n const mm = /^(-?\\d+(?:\\.\\d+)?)(px|em|rem|%)?$/.exec(rootMargin || '');\n const mv = mm ? parseFloat(mm[1]) : 0;\n const mu = mm ? mm[2] || 'px' : 'px';\n const sign = mv === 0 ? '' : mv < 0 ? `-=${Math.abs(mv)}${mu}` : `+=${mv}${mu}`;\n return `top ${startPct}%${sign}`;\n }, [threshold, rootMargin]);\n\n useGSAP(\n () => {\n if (!ref.current || !text || !fontsLoaded) return;\n if (respectReducedMotion && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {\n onShuffleComplete?.();\n return;\n }\n\n const el = ref.current as HTMLElement;\n const start = scrollTriggerStart;\n\n const removeHover = () => {\n if (hoverHandlerRef.current && ref.current) {\n ref.current.removeEventListener('mouseenter', hoverHandlerRef.current);\n hoverHandlerRef.current = null;\n }\n };\n\n const teardown = () => {\n if (tlRef.current) {\n tlRef.current.kill();\n tlRef.current = null;\n }\n if (wrappersRef.current.length) {\n wrappersRef.current.forEach(wrap => {\n const inner = wrap.firstElementChild as HTMLElement | null;\n const orig = inner?.querySelector('[data-orig=\"1\"]') as HTMLElement | null;\n if (orig && wrap.parentNode) wrap.parentNode.replaceChild(orig, wrap);\n });\n wrappersRef.current = [];\n }\n try {\n splitRef.current?.revert();\n } catch {}\n splitRef.current = null;\n playingRef.current = false;\n };\n\n const build = () => {\n teardown();\n\n splitRef.current = new GSAPSplitText(el, {\n type: 'chars',\n charsClass: 'shuffle-char',\n wordsClass: 'shuffle-word',\n linesClass: 'shuffle-line',\n smartWrap: true,\n reduceWhiteSpace: false\n });\n\n const chars = (splitRef.current.chars || []) as HTMLElement[];\n wrappersRef.current = [];\n\n const rolls = Math.max(1, Math.floor(shuffleTimes));\n const rand = (set: string) => set.charAt(Math.floor(Math.random() * set.length)) || '';\n\n chars.forEach(ch => {\n const parent = ch.parentElement;\n if (!parent) return;\n\n const w = ch.getBoundingClientRect().width;\n const h = ch.getBoundingClientRect().height;\n if (!w) return;\n\n const wrap = document.createElement('span');\n Object.assign(wrap.style, {\n display: 'inline-block',\n overflow: 'hidden',\n width: w + 'px',\n height: shuffleDirection === 'up' || shuffleDirection === 'down' ? h + 'px' : 'auto',\n verticalAlign: 'bottom'\n });\n\n const inner = document.createElement('span');\n Object.assign(inner.style, {\n display: 'inline-block',\n whiteSpace: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'normal' : 'nowrap',\n willChange: 'transform'\n });\n\n parent.insertBefore(wrap, ch);\n wrap.appendChild(inner);\n\n const firstOrig = ch.cloneNode(true) as HTMLElement;\n Object.assign(firstOrig.style, {\n display: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block',\n width: w + 'px',\n textAlign: 'center'\n });\n\n ch.setAttribute('data-orig', '1');\n Object.assign(ch.style, {\n display: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block',\n width: w + 'px',\n textAlign: 'center'\n });\n\n inner.appendChild(firstOrig);\n for (let k = 0; k < rolls; k++) {\n const c = ch.cloneNode(true) as HTMLElement;\n if (scrambleCharset) c.textContent = rand(scrambleCharset);\n Object.assign(c.style, {\n display: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block',\n width: w + 'px',\n textAlign: 'center'\n });\n inner.appendChild(c);\n }\n inner.appendChild(ch);\n\n const steps = rolls + 1;\n\n if (shuffleDirection === 'right' || shuffleDirection === 'down') {\n const firstCopy = inner.firstElementChild as HTMLElement | null;\n const real = inner.lastElementChild as HTMLElement | null;\n if (real) inner.insertBefore(real, inner.firstChild);\n if (firstCopy) inner.appendChild(firstCopy);\n }\n\n let startX = 0;\n let finalX = 0;\n let startY = 0;\n let finalY = 0;\n\n if (shuffleDirection === 'right') {\n startX = -steps * w;\n finalX = 0;\n } else if (shuffleDirection === 'left') {\n startX = 0;\n finalX = -steps * w;\n } else if (shuffleDirection === 'down') {\n startY = -steps * h;\n finalY = 0;\n } else if (shuffleDirection === 'up') {\n startY = 0;\n finalY = -steps * h;\n }\n\n if (shuffleDirection === 'left' || shuffleDirection === 'right') {\n gsap.set(inner, { x: startX, y: 0, force3D: true });\n inner.setAttribute('data-start-x', String(startX));\n inner.setAttribute('data-final-x', String(finalX));\n } else {\n gsap.set(inner, { x: 0, y: startY, force3D: true });\n inner.setAttribute('data-start-y', String(startY));\n inner.setAttribute('data-final-y', String(finalY));\n }\n\n if (colorFrom) (inner.style as any).color = colorFrom;\n wrappersRef.current.push(wrap);\n });\n };\n\n const inners = () => wrappersRef.current.map(w => w.firstElementChild as HTMLElement);\n\n const randomizeScrambles = () => {\n if (!scrambleCharset) return;\n wrappersRef.current.forEach(w => {\n const strip = w.firstElementChild as HTMLElement;\n if (!strip) return;\n const kids = Array.from(strip.children) as HTMLElement[];\n for (let i = 1; i < kids.length - 1; i++) {\n kids[i].textContent = scrambleCharset.charAt(Math.floor(Math.random() * scrambleCharset.length));\n }\n });\n };\n\n const cleanupToStill = () => {\n wrappersRef.current.forEach(w => {\n const strip = w.firstElementChild as HTMLElement;\n if (!strip) return;\n const real = strip.querySelector('[data-orig=\"1\"]') as HTMLElement | null;\n if (!real) return;\n strip.replaceChildren(real);\n strip.style.transform = 'none';\n strip.style.willChange = 'auto';\n });\n };\n\n const play = () => {\n const strips = inners();\n if (!strips.length) return;\n\n playingRef.current = true;\n const isVertical = shuffleDirection === 'up' || shuffleDirection === 'down';\n\n const tl = gsap.timeline({\n smoothChildTiming: true,\n repeat: loop ? -1 : 0,\n repeatDelay: loop ? loopDelay : 0,\n onRepeat: () => {\n if (scrambleCharset) randomizeScrambles();\n if (isVertical) {\n gsap.set(strips, { y: (i, t: HTMLElement) => parseFloat(t.getAttribute('data-start-y') || '0') });\n } else {\n gsap.set(strips, { x: (i, t: HTMLElement) => parseFloat(t.getAttribute('data-start-x') || '0') });\n }\n onShuffleComplete?.();\n },\n onComplete: () => {\n playingRef.current = false;\n if (!loop) {\n cleanupToStill();\n if (colorTo) gsap.set(strips, { color: colorTo });\n onShuffleComplete?.();\n armHover();\n }\n }\n });\n\n const addTween = (targets: HTMLElement[], at: number) => {\n const vars: any = {\n duration,\n ease,\n force3D: true,\n stagger: animationMode === 'evenodd' ? stagger : 0\n };\n if (isVertical) {\n vars.y = (i: number, t: HTMLElement) => parseFloat(t.getAttribute('data-final-y') || '0');\n } else {\n vars.x = (i: number, t: HTMLElement) => parseFloat(t.getAttribute('data-final-x') || '0');\n }\n\n tl.to(targets, vars, at);\n\n if (colorFrom && colorTo) {\n tl.to(targets, { color: colorTo, duration, ease }, at);\n }\n };\n\n if (animationMode === 'evenodd') {\n const odd = strips.filter((_, i) => i % 2 === 1);\n const even = strips.filter((_, i) => i % 2 === 0);\n const oddTotal = duration + Math.max(0, odd.length - 1) * stagger;\n const evenStart = odd.length ? oddTotal * 0.7 : 0;\n if (odd.length) addTween(odd, 0);\n if (even.length) addTween(even, evenStart);\n } else {\n strips.forEach(strip => {\n const d = Math.random() * maxDelay;\n const vars: any = {\n duration,\n ease,\n force3D: true\n };\n if (isVertical) {\n vars.y = parseFloat(strip.getAttribute('data-final-y') || '0');\n } else {\n vars.x = parseFloat(strip.getAttribute('data-final-x') || '0');\n }\n tl.to(strip, vars, d);\n if (colorFrom && colorTo) tl.fromTo(strip, { color: colorFrom }, { color: colorTo, duration, ease }, d);\n });\n }\n\n tlRef.current = tl;\n };\n\n const armHover = () => {\n if (!triggerOnHover || !ref.current) return;\n removeHover();\n const handler = () => {\n if (playingRef.current) return;\n build();\n if (scrambleCharset) randomizeScrambles();\n play();\n };\n hoverHandlerRef.current = handler;\n ref.current.addEventListener('mouseenter', handler);\n };\n\n const create = () => {\n build();\n if (scrambleCharset) randomizeScrambles();\n play();\n armHover();\n setReady(true);\n };\n\n const st = ScrollTrigger.create({\n trigger: el,\n start,\n once: triggerOnce,\n onEnter: create\n });\n\n return () => {\n st.kill();\n removeHover();\n teardown();\n setReady(false);\n };\n },\n {\n dependencies: [\n text,\n duration,\n maxDelay,\n ease,\n scrollTriggerStart,\n fontsLoaded,\n shuffleDirection,\n shuffleTimes,\n animationMode,\n loop,\n loopDelay,\n stagger,\n scrambleCharset,\n colorFrom,\n colorTo,\n triggerOnce,\n respectReducedMotion,\n triggerOnHover,\n onShuffleComplete\n ],\n scope: ref\n }\n );\n\n const commonStyle: React.CSSProperties = useMemo(() => ({ textAlign, ...style }), [textAlign, style]);\n const classes = useMemo(() => `shuffle-parent ${ready ? 'is-ready' : ''} ${className}`, [ready, className]);\n const Tag = (tag || 'p') as keyof JSX.IntrinsicElements;\n return React.createElement(Tag, { ref: ref as any, className: classes, style: commonStyle }, text);\n};\n\nexport default Shuffle;\n" + "content": "import React, { useRef, useEffect, useState, useMemo } from 'react';\nimport { gsap } from 'gsap';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { SplitText as GSAPSplitText } from 'gsap/SplitText';\nimport { useGSAP } from '@gsap/react';\nimport './Shuffle.css';\n\ngsap.registerPlugin(ScrollTrigger, GSAPSplitText, useGSAP);\n\nexport interface ShuffleProps {\n text: string;\n className?: string;\n style?: React.CSSProperties;\n shuffleDirection?: 'left' | 'right' | 'up' | 'down';\n duration?: number;\n maxDelay?: number;\n ease?: string | ((t: number) => number);\n threshold?: number;\n rootMargin?: string;\n tag?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span';\n textAlign?: React.CSSProperties['textAlign'];\n onShuffleComplete?: () => void;\n shuffleTimes?: number;\n animationMode?: 'random' | 'evenodd';\n loop?: boolean;\n loopDelay?: number;\n stagger?: number;\n scrambleCharset?: string;\n colorFrom?: string;\n colorTo?: string;\n triggerOnce?: boolean;\n respectReducedMotion?: boolean;\n triggerOnHover?: boolean;\n}\n\nconst Shuffle: React.FC = ({\n text,\n className = '',\n style = {},\n shuffleDirection = 'right',\n duration = 0.35,\n maxDelay = 0,\n ease = 'power3.out',\n threshold = 0.1,\n rootMargin = '-100px',\n tag = 'p',\n textAlign = 'center',\n onShuffleComplete,\n shuffleTimes = 1,\n animationMode = 'evenodd',\n loop = false,\n loopDelay = 0,\n stagger = 0.03,\n scrambleCharset = '',\n colorFrom,\n colorTo,\n triggerOnce = true,\n respectReducedMotion = true,\n triggerOnHover = true\n}) => {\n const ref = useRef(null);\n const [fontsLoaded, setFontsLoaded] = useState(false);\n const [ready, setReady] = useState(false);\n\n const splitRef = useRef(null);\n const wrappersRef = useRef([]);\n const tlRef = useRef(null);\n const playingRef = useRef(false);\n const hoverHandlerRef = useRef<((e: Event) => void) | null>(null);\n\n useEffect(() => {\n if ('fonts' in document) {\n if (document.fonts.status === 'loaded') setFontsLoaded(true);\n else document.fonts.ready.then(() => setFontsLoaded(true));\n } else setFontsLoaded(true);\n }, []);\n\n const scrollTriggerStart = useMemo(() => {\n const startPct = (1 - threshold) * 100;\n const mm = /^(-?\\d+(?:\\.\\d+)?)(px|em|rem|%)?$/.exec(rootMargin || '');\n const mv = mm ? parseFloat(mm[1]) : 0;\n const mu = mm ? mm[2] || 'px' : 'px';\n const sign = mv === 0 ? '' : mv < 0 ? `-=${Math.abs(mv)}${mu}` : `+=${mv}${mu}`;\n return `top ${startPct}%${sign}`;\n }, [threshold, rootMargin]);\n\n useGSAP(\n () => {\n if (!ref.current || !text || !fontsLoaded) return;\n if (respectReducedMotion && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {\n onShuffleComplete?.();\n return;\n }\n\n const el = ref.current as HTMLElement;\n const start = scrollTriggerStart;\n\n const removeHover = () => {\n if (hoverHandlerRef.current && ref.current) {\n ref.current.removeEventListener('mouseenter', hoverHandlerRef.current);\n hoverHandlerRef.current = null;\n }\n };\n\n const teardown = () => {\n if (tlRef.current) {\n tlRef.current.kill();\n tlRef.current = null;\n }\n if (wrappersRef.current.length) {\n wrappersRef.current.forEach(wrap => {\n const inner = wrap.firstElementChild as HTMLElement | null;\n const orig = inner?.querySelector('[data-orig=\"1\"]') as HTMLElement | null;\n if (orig && wrap.parentNode) wrap.parentNode.replaceChild(orig, wrap);\n });\n wrappersRef.current = [];\n }\n try {\n splitRef.current?.revert();\n } catch {}\n splitRef.current = null;\n playingRef.current = false;\n };\n\n const build = () => {\n teardown();\n\n splitRef.current = new GSAPSplitText(el, {\n type: 'chars',\n charsClass: 'shuffle-char',\n wordsClass: 'shuffle-word',\n linesClass: 'shuffle-line',\n smartWrap: true,\n reduceWhiteSpace: false\n });\n\n const chars = (splitRef.current.chars || []) as HTMLElement[];\n wrappersRef.current = [];\n\n const rolls = Math.max(1, Math.floor(shuffleTimes));\n const rand = (set: string) => set.charAt(Math.floor(Math.random() * set.length)) || '';\n const isVertical = shuffleDirection === 'up' || shuffleDirection === 'down';\n const metricsContext = isVertical ? document.createElement('canvas').getContext('2d') : null;\n\n const measureVerticalCell = (node: HTMLElement, lineBoxHeight: number) => {\n const computed = window.getComputedStyle(node);\n let fontHeight = 0;\n\n if (metricsContext) {\n metricsContext.font = [\n computed.fontStyle,\n computed.fontVariant,\n computed.fontWeight,\n computed.fontSize,\n computed.fontFamily\n ].join(' ');\n\n const sample = `${node.textContent || 'M'}${scrambleCharset}`;\n const metrics = metricsContext.measureText(sample);\n const ascent = metrics.fontBoundingBoxAscent;\n const descent = metrics.fontBoundingBoxDescent;\n if (Number.isFinite(ascent) && Number.isFinite(descent)) fontHeight = ascent + descent;\n }\n\n if (!fontHeight) {\n const probe = node.cloneNode(true) as HTMLElement;\n probe.textContent = `${node.textContent || 'M'}${scrambleCharset}`;\n Object.assign(probe.style, {\n position: 'absolute',\n visibility: 'hidden',\n pointerEvents: 'none',\n width: 'auto',\n height: 'auto',\n whiteSpace: 'nowrap',\n lineHeight: 'normal',\n fontFamily: computed.fontFamily,\n fontSize: computed.fontSize,\n fontStyle: computed.fontStyle,\n fontVariant: computed.fontVariant,\n fontWeight: computed.fontWeight,\n fontStretch: computed.fontStretch\n });\n document.body.appendChild(probe);\n fontHeight = probe.getBoundingClientRect().height;\n probe.remove();\n }\n\n const overflow = Math.max(0, Math.ceil(fontHeight - lineBoxHeight));\n const padTop = Math.floor(overflow / 2);\n return { cellHeight: lineBoxHeight + overflow, padTop, padBottom: overflow - padTop };\n };\n\n chars.forEach(ch => {\n const parent = ch.parentElement;\n if (!parent) return;\n\n const w = ch.getBoundingClientRect().width;\n const h = ch.getBoundingClientRect().height;\n if (!w) return;\n\n const { cellHeight, padTop, padBottom } = isVertical\n ? measureVerticalCell(ch, h)\n : { cellHeight: h, padTop: 0, padBottom: 0 };\n\n const wrap = document.createElement('span');\n Object.assign(wrap.style, {\n display: 'inline-block',\n overflow: 'hidden',\n width: w + 'px',\n height: isVertical ? cellHeight + 'px' : 'auto',\n marginTop: isVertical ? -padTop + 'px' : '0',\n marginBottom: isVertical ? -padBottom + 'px' : '0',\n verticalAlign: 'bottom'\n });\n\n const inner = document.createElement('span');\n Object.assign(inner.style, {\n display: 'inline-block',\n whiteSpace: isVertical ? 'normal' : 'nowrap',\n willChange: 'transform'\n });\n\n parent.insertBefore(wrap, ch);\n wrap.appendChild(inner);\n\n const firstOrig = ch.cloneNode(true) as HTMLElement;\n Object.assign(firstOrig.style, {\n display: isVertical ? 'flex' : 'inline-block',\n alignItems: isVertical ? 'center' : '',\n justifyContent: isVertical ? 'center' : '',\n height: isVertical ? cellHeight + 'px' : '',\n lineHeight: isVertical ? h + 'px' : '',\n width: w + 'px',\n textAlign: 'center'\n });\n\n ch.setAttribute('data-orig', '1');\n Object.assign(ch.style, {\n display: isVertical ? 'flex' : 'inline-block',\n alignItems: isVertical ? 'center' : '',\n justifyContent: isVertical ? 'center' : '',\n height: isVertical ? cellHeight + 'px' : '',\n lineHeight: isVertical ? h + 'px' : '',\n width: w + 'px',\n textAlign: 'center'\n });\n\n inner.appendChild(firstOrig);\n for (let k = 0; k < rolls; k++) {\n const c = ch.cloneNode(true) as HTMLElement;\n if (scrambleCharset) c.textContent = rand(scrambleCharset);\n Object.assign(c.style, {\n display: isVertical ? 'flex' : 'inline-block',\n alignItems: isVertical ? 'center' : '',\n justifyContent: isVertical ? 'center' : '',\n height: isVertical ? cellHeight + 'px' : '',\n lineHeight: isVertical ? h + 'px' : '',\n width: w + 'px',\n textAlign: 'center'\n });\n inner.appendChild(c);\n }\n inner.appendChild(ch);\n\n const steps = rolls + 1;\n\n if (shuffleDirection === 'right' || shuffleDirection === 'down') {\n const firstCopy = inner.firstElementChild as HTMLElement | null;\n const real = inner.lastElementChild as HTMLElement | null;\n if (real) inner.insertBefore(real, inner.firstChild);\n if (firstCopy) inner.appendChild(firstCopy);\n }\n\n let startX = 0;\n let finalX = 0;\n let startY = 0;\n let finalY = 0;\n\n if (shuffleDirection === 'right') {\n startX = -steps * w;\n finalX = 0;\n } else if (shuffleDirection === 'left') {\n startX = 0;\n finalX = -steps * w;\n } else if (shuffleDirection === 'down') {\n startY = -steps * cellHeight;\n finalY = 0;\n } else if (shuffleDirection === 'up') {\n startY = 0;\n finalY = -steps * cellHeight;\n }\n\n if (shuffleDirection === 'left' || shuffleDirection === 'right') {\n gsap.set(inner, { x: startX, y: 0, force3D: true });\n inner.setAttribute('data-start-x', String(startX));\n inner.setAttribute('data-final-x', String(finalX));\n } else {\n gsap.set(inner, { x: 0, y: startY, force3D: true });\n inner.setAttribute('data-start-y', String(startY));\n inner.setAttribute('data-final-y', String(finalY));\n }\n\n if (colorFrom) (inner.style as any).color = colorFrom;\n wrappersRef.current.push(wrap);\n });\n };\n\n const inners = () => wrappersRef.current.map(w => w.firstElementChild as HTMLElement);\n\n const randomizeScrambles = () => {\n if (!scrambleCharset) return;\n wrappersRef.current.forEach(w => {\n const strip = w.firstElementChild as HTMLElement;\n if (!strip) return;\n const kids = Array.from(strip.children) as HTMLElement[];\n for (let i = 1; i < kids.length - 1; i++) {\n kids[i].textContent = scrambleCharset.charAt(Math.floor(Math.random() * scrambleCharset.length));\n }\n });\n };\n\n const cleanupToStill = () => {\n wrappersRef.current.forEach(w => {\n const strip = w.firstElementChild as HTMLElement;\n if (!strip) return;\n const real = strip.querySelector('[data-orig=\"1\"]') as HTMLElement | null;\n if (!real) return;\n strip.replaceChildren(real);\n strip.style.transform = 'none';\n strip.style.willChange = 'auto';\n });\n };\n\n const play = () => {\n const strips = inners();\n if (!strips.length) return;\n\n playingRef.current = true;\n const isVertical = shuffleDirection === 'up' || shuffleDirection === 'down';\n\n const tl = gsap.timeline({\n smoothChildTiming: true,\n repeat: loop ? -1 : 0,\n repeatDelay: loop ? loopDelay : 0,\n onRepeat: () => {\n if (scrambleCharset) randomizeScrambles();\n if (isVertical) {\n gsap.set(strips, { y: (i, t: HTMLElement) => parseFloat(t.getAttribute('data-start-y') || '0') });\n } else {\n gsap.set(strips, { x: (i, t: HTMLElement) => parseFloat(t.getAttribute('data-start-x') || '0') });\n }\n onShuffleComplete?.();\n },\n onComplete: () => {\n playingRef.current = false;\n if (!loop) {\n cleanupToStill();\n if (colorTo) gsap.set(strips, { color: colorTo });\n onShuffleComplete?.();\n armHover();\n }\n }\n });\n\n const addTween = (targets: HTMLElement[], at: number) => {\n const vars: any = {\n duration,\n ease,\n force3D: true,\n stagger: animationMode === 'evenodd' ? stagger : 0\n };\n if (isVertical) {\n vars.y = (i: number, t: HTMLElement) => parseFloat(t.getAttribute('data-final-y') || '0');\n } else {\n vars.x = (i: number, t: HTMLElement) => parseFloat(t.getAttribute('data-final-x') || '0');\n }\n\n tl.to(targets, vars, at);\n\n if (colorFrom && colorTo) {\n tl.to(targets, { color: colorTo, duration, ease }, at);\n }\n };\n\n if (animationMode === 'evenodd') {\n const odd = strips.filter((_, i) => i % 2 === 1);\n const even = strips.filter((_, i) => i % 2 === 0);\n const oddTotal = duration + Math.max(0, odd.length - 1) * stagger;\n const evenStart = odd.length ? oddTotal * 0.7 : 0;\n if (odd.length) addTween(odd, 0);\n if (even.length) addTween(even, evenStart);\n } else {\n strips.forEach(strip => {\n const d = Math.random() * maxDelay;\n const vars: any = {\n duration,\n ease,\n force3D: true\n };\n if (isVertical) {\n vars.y = parseFloat(strip.getAttribute('data-final-y') || '0');\n } else {\n vars.x = parseFloat(strip.getAttribute('data-final-x') || '0');\n }\n tl.to(strip, vars, d);\n if (colorFrom && colorTo) tl.fromTo(strip, { color: colorFrom }, { color: colorTo, duration, ease }, d);\n });\n }\n\n tlRef.current = tl;\n };\n\n const armHover = () => {\n if (!triggerOnHover || !ref.current) return;\n removeHover();\n const handler = () => {\n if (playingRef.current) return;\n build();\n if (scrambleCharset) randomizeScrambles();\n play();\n };\n hoverHandlerRef.current = handler;\n ref.current.addEventListener('mouseenter', handler);\n };\n\n const create = () => {\n build();\n if (scrambleCharset) randomizeScrambles();\n play();\n armHover();\n setReady(true);\n };\n\n const st = ScrollTrigger.create({\n trigger: el,\n start,\n once: triggerOnce,\n onEnter: create\n });\n\n return () => {\n st.kill();\n removeHover();\n teardown();\n setReady(false);\n };\n },\n {\n dependencies: [\n text,\n duration,\n maxDelay,\n ease,\n scrollTriggerStart,\n fontsLoaded,\n shuffleDirection,\n shuffleTimes,\n animationMode,\n loop,\n loopDelay,\n stagger,\n scrambleCharset,\n colorFrom,\n colorTo,\n triggerOnce,\n respectReducedMotion,\n triggerOnHover,\n onShuffleComplete\n ],\n scope: ref\n }\n );\n\n const commonStyle: React.CSSProperties = useMemo(() => ({ textAlign, ...style }), [textAlign, style]);\n const classes = useMemo(() => `shuffle-parent ${ready ? 'is-ready' : ''} ${className}`, [ready, className]);\n const Tag = (tag || 'p') as keyof JSX.IntrinsicElements;\n return React.createElement(Tag, { ref: ref as any, className: classes, style: commonStyle }, text);\n};\n\nexport default Shuffle;\n" } ], "registryDependencies": [], diff --git a/public/r/Shuffle-TS-TW.json b/public/r/Shuffle-TS-TW.json index 58a37c97c..85618d8c3 100644 --- a/public/r/Shuffle-TS-TW.json +++ b/public/r/Shuffle-TS-TW.json @@ -8,7 +8,7 @@ { "type": "registry:component", "path": "Shuffle/Shuffle.tsx", - "content": "import React, { useRef, useEffect, useState, useMemo } from 'react';\nimport { gsap } from 'gsap';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { SplitText as GSAPSplitText } from 'gsap/SplitText';\nimport { useGSAP } from '@gsap/react';\nimport { type JSX } from 'react';\n\ngsap.registerPlugin(ScrollTrigger, GSAPSplitText);\n\nexport interface ShuffleProps {\n text: string;\n className?: string;\n style?: React.CSSProperties;\n shuffleDirection?: 'left' | 'right' | 'up' | 'down';\n duration?: number;\n maxDelay?: number;\n ease?: string | ((t: number) => number);\n threshold?: number;\n rootMargin?: string;\n tag?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span';\n textAlign?: React.CSSProperties['textAlign'];\n onShuffleComplete?: () => void;\n shuffleTimes?: number;\n animationMode?: 'random' | 'evenodd';\n loop?: boolean;\n loopDelay?: number;\n stagger?: number;\n scrambleCharset?: string;\n colorFrom?: string;\n colorTo?: string;\n triggerOnce?: boolean;\n respectReducedMotion?: boolean;\n triggerOnHover?: boolean;\n}\n\nconst Shuffle: React.FC = ({\n text,\n className = '',\n style = {},\n shuffleDirection = 'right',\n duration = 0.35,\n maxDelay = 0,\n ease = 'power3.out',\n threshold = 0.1,\n rootMargin = '-100px',\n tag = 'p',\n textAlign = 'center',\n onShuffleComplete,\n shuffleTimes = 1,\n animationMode = 'evenodd',\n loop = false,\n loopDelay = 0,\n stagger = 0.03,\n scrambleCharset = '',\n colorFrom,\n colorTo,\n triggerOnce = true,\n respectReducedMotion = true,\n triggerOnHover = true\n}) => {\n const ref = useRef(null);\n const [fontsLoaded, setFontsLoaded] = useState(false);\n const [ready, setReady] = useState(false);\n\n const splitRef = useRef(null);\n const wrappersRef = useRef([]);\n const tlRef = useRef(null);\n const playingRef = useRef(false);\n const hoverHandlerRef = useRef<((e: Event) => void) | null>(null);\n\n useEffect(() => {\n if ('fonts' in document) {\n if (document.fonts.status === 'loaded') setFontsLoaded(true);\n else document.fonts.ready.then(() => setFontsLoaded(true));\n } else setFontsLoaded(true);\n }, []);\n\n const scrollTriggerStart = useMemo(() => {\n const startPct = (1 - threshold) * 100;\n const mm = /^(-?\\d+(?:\\.\\d+)?)(px|em|rem|%)?$/.exec(rootMargin || '');\n const mv = mm ? parseFloat(mm[1]) : 0;\n const mu = mm ? mm[2] || 'px' : 'px';\n const sign = mv === 0 ? '' : mv < 0 ? `-=${Math.abs(mv)}${mu}` : `+=${mv}${mu}`;\n return `top ${startPct}%${sign}`;\n }, [threshold, rootMargin]);\n\n useGSAP(\n () => {\n if (!ref.current || !text || !fontsLoaded) return;\n if (respectReducedMotion && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {\n onShuffleComplete?.();\n return;\n }\n\n const el = ref.current as HTMLElement;\n const start = scrollTriggerStart;\n\n const removeHover = () => {\n if (hoverHandlerRef.current && ref.current) {\n ref.current.removeEventListener('mouseenter', hoverHandlerRef.current);\n hoverHandlerRef.current = null;\n }\n };\n\n const teardown = () => {\n if (tlRef.current) {\n tlRef.current.kill();\n tlRef.current = null;\n }\n if (wrappersRef.current.length) {\n wrappersRef.current.forEach(wrap => {\n const inner = wrap.firstElementChild as HTMLElement | null;\n const orig = inner?.querySelector('[data-orig=\"1\"]') as HTMLElement | null;\n if (orig && wrap.parentNode) wrap.parentNode.replaceChild(orig, wrap);\n });\n wrappersRef.current = [];\n }\n try {\n splitRef.current?.revert();\n } catch {}\n splitRef.current = null;\n playingRef.current = false;\n };\n\n const build = () => {\n teardown();\n\n const computedFont = getComputedStyle(el).fontFamily;\n\n splitRef.current = new GSAPSplitText(el, {\n type: 'chars',\n charsClass: 'shuffle-char',\n wordsClass: 'shuffle-word',\n linesClass: 'shuffle-line',\n smartWrap: true,\n reduceWhiteSpace: false\n });\n\n const chars = (splitRef.current.chars || []) as HTMLElement[];\n wrappersRef.current = [];\n\n const rolls = Math.max(1, Math.floor(shuffleTimes));\n const rand = (set: string) => set.charAt(Math.floor(Math.random() * set.length)) || '';\n\n chars.forEach(ch => {\n const parent = ch.parentElement;\n if (!parent) return;\n\n const w = ch.getBoundingClientRect().width;\n const h = ch.getBoundingClientRect().height;\n if (!w) return;\n\n const wrap = document.createElement('span');\n wrap.className = 'inline-block overflow-hidden text-left';\n Object.assign(wrap.style, {\n width: w + 'px',\n height: shuffleDirection === 'up' || shuffleDirection === 'down' ? h + 'px' : 'auto',\n verticalAlign: 'bottom'\n });\n\n const inner = document.createElement('span');\n inner.className =\n 'inline-block will-change-transform origin-left transform-gpu ' +\n (shuffleDirection === 'up' || shuffleDirection === 'down' ? 'whitespace-normal' : 'whitespace-nowrap');\n\n parent.insertBefore(wrap, ch);\n wrap.appendChild(inner);\n\n const firstOrig = ch.cloneNode(true) as HTMLElement;\n firstOrig.className =\n 'text-left ' + (shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block');\n Object.assign(firstOrig.style, { width: w + 'px', fontFamily: computedFont });\n\n ch.setAttribute('data-orig', '1');\n ch.className =\n 'text-left ' + (shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block');\n Object.assign(ch.style, { width: w + 'px', fontFamily: computedFont });\n\n inner.appendChild(firstOrig);\n for (let k = 0; k < rolls; k++) {\n const c = ch.cloneNode(true) as HTMLElement;\n if (scrambleCharset) c.textContent = rand(scrambleCharset);\n c.className =\n 'text-left ' + (shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block');\n Object.assign(c.style, { width: w + 'px', fontFamily: computedFont });\n inner.appendChild(c);\n }\n inner.appendChild(ch);\n\n const steps = rolls + 1;\n\n if (shuffleDirection === 'right' || shuffleDirection === 'down') {\n const firstCopy = inner.firstElementChild as HTMLElement | null;\n const real = inner.lastElementChild as HTMLElement | null;\n if (real) inner.insertBefore(real, inner.firstChild);\n if (firstCopy) inner.appendChild(firstCopy);\n }\n\n let startX = 0;\n let finalX = 0;\n let startY = 0;\n let finalY = 0;\n\n if (shuffleDirection === 'right') {\n startX = -steps * w;\n finalX = 0;\n } else if (shuffleDirection === 'left') {\n startX = 0;\n finalX = -steps * w;\n } else if (shuffleDirection === 'down') {\n startY = -steps * h;\n finalY = 0;\n } else if (shuffleDirection === 'up') {\n startY = 0;\n finalY = -steps * h;\n }\n\n if (shuffleDirection === 'left' || shuffleDirection === 'right') {\n gsap.set(inner, { x: startX, y: 0, force3D: true });\n inner.setAttribute('data-start-x', String(startX));\n inner.setAttribute('data-final-x', String(finalX));\n } else {\n gsap.set(inner, { x: 0, y: startY, force3D: true });\n inner.setAttribute('data-start-y', String(startY));\n inner.setAttribute('data-final-y', String(finalY));\n }\n\n if (colorFrom) (inner.style as any).color = colorFrom;\n wrappersRef.current.push(wrap);\n });\n };\n\n const inners = () => wrappersRef.current.map(w => w.firstElementChild as HTMLElement);\n\n const randomizeScrambles = () => {\n if (!scrambleCharset) return;\n wrappersRef.current.forEach(w => {\n const strip = w.firstElementChild as HTMLElement;\n if (!strip) return;\n const kids = Array.from(strip.children) as HTMLElement[];\n for (let i = 1; i < kids.length - 1; i++) {\n kids[i].textContent = scrambleCharset.charAt(Math.floor(Math.random() * scrambleCharset.length));\n }\n });\n };\n\n const cleanupToStill = () => {\n wrappersRef.current.forEach(w => {\n const strip = w.firstElementChild as HTMLElement;\n if (!strip) return;\n const real = strip.querySelector('[data-orig=\"1\"]') as HTMLElement | null;\n if (!real) return;\n strip.replaceChildren(real);\n strip.style.transform = 'none';\n strip.style.willChange = 'auto';\n });\n };\n\n const play = () => {\n const strips = inners();\n if (!strips.length) return;\n\n playingRef.current = true;\n const isVertical = shuffleDirection === 'up' || shuffleDirection === 'down';\n\n const tl = gsap.timeline({\n smoothChildTiming: true,\n repeat: loop ? -1 : 0,\n repeatDelay: loop ? loopDelay : 0,\n onRepeat: () => {\n if (scrambleCharset) randomizeScrambles();\n if (isVertical) {\n gsap.set(strips, { y: (i, t: HTMLElement) => parseFloat(t.getAttribute('data-start-y') || '0') });\n } else {\n gsap.set(strips, { x: (i, t: HTMLElement) => parseFloat(t.getAttribute('data-start-x') || '0') });\n }\n onShuffleComplete?.();\n },\n onComplete: () => {\n playingRef.current = false;\n if (!loop) {\n cleanupToStill();\n if (colorTo) gsap.set(strips, { color: colorTo });\n onShuffleComplete?.();\n armHover();\n }\n }\n });\n\n const addTween = (targets: HTMLElement[], at: number) => {\n const vars: any = {\n duration,\n ease,\n force3D: true,\n stagger: animationMode === 'evenodd' ? stagger : 0\n };\n if (isVertical) {\n vars.y = (i: number, t: HTMLElement) => parseFloat(t.getAttribute('data-final-y') || '0');\n } else {\n vars.x = (i: number, t: HTMLElement) => parseFloat(t.getAttribute('data-final-x') || '0');\n }\n\n tl.to(targets, vars, at);\n\n if (colorFrom && colorTo) tl.to(targets, { color: colorTo, duration, ease }, at);\n };\n\n if (animationMode === 'evenodd') {\n const odd = strips.filter((_, i) => i % 2 === 1);\n const even = strips.filter((_, i) => i % 2 === 0);\n const oddTotal = duration + Math.max(0, odd.length - 1) * stagger;\n const evenStart = odd.length ? oddTotal * 0.7 : 0;\n if (odd.length) addTween(odd, 0);\n if (even.length) addTween(even, evenStart);\n } else {\n strips.forEach(strip => {\n const d = Math.random() * maxDelay;\n const vars: any = {\n duration,\n ease,\n force3D: true\n };\n if (isVertical) {\n vars.y = parseFloat(strip.getAttribute('data-final-y') || '0');\n } else {\n vars.x = parseFloat(strip.getAttribute('data-final-x') || '0');\n }\n tl.to(strip, vars, d);\n if (colorFrom && colorTo) tl.fromTo(strip, { color: colorFrom }, { color: colorTo, duration, ease }, d);\n });\n }\n\n tlRef.current = tl;\n };\n\n const armHover = () => {\n if (!triggerOnHover || !ref.current) return;\n removeHover();\n const handler = () => {\n if (playingRef.current) return;\n build();\n if (scrambleCharset) randomizeScrambles();\n play();\n };\n hoverHandlerRef.current = handler;\n ref.current.addEventListener('mouseenter', handler);\n };\n\n const create = () => {\n build();\n if (scrambleCharset) randomizeScrambles();\n play();\n armHover();\n setReady(true);\n };\n\n const st = ScrollTrigger.create({\n trigger: el,\n start,\n once: triggerOnce,\n onEnter: create\n });\n\n return () => {\n st.kill();\n removeHover();\n teardown();\n setReady(false);\n };\n },\n {\n dependencies: [\n text,\n duration,\n maxDelay,\n ease,\n scrollTriggerStart,\n fontsLoaded,\n shuffleDirection,\n shuffleTimes,\n animationMode,\n loop,\n loopDelay,\n stagger,\n scrambleCharset,\n colorFrom,\n colorTo,\n triggerOnce,\n respectReducedMotion,\n triggerOnHover,\n onShuffleComplete\n ],\n scope: ref\n }\n );\n\n const baseTw = 'inline-block whitespace-normal break-words will-change-transform uppercase text-2xl leading-none';\n const userHasFont = useMemo(() => className && /font[-[]/i.test(className), [className]);\n\n const fallbackFont = useMemo(\n () => (userHasFont ? {} : { fontFamily: `'Press Start 2P', sans-serif` }),\n [userHasFont]\n );\n\n const commonStyle = useMemo(\n () => ({\n textAlign,\n ...fallbackFont,\n ...style\n }),\n [textAlign, fallbackFont, style]\n );\n\n const classes = useMemo(\n () => `${baseTw} ${ready ? 'visible' : 'invisible'} ${className}`.trim(),\n [baseTw, ready, className]\n );\n const Tag = (tag || 'p') as keyof JSX.IntrinsicElements;\n\n return React.createElement(Tag, { ref: ref as any, className: classes, style: commonStyle }, text);\n};\n\nexport default Shuffle;\n" + "content": "import React, { useRef, useEffect, useState, useMemo } from 'react';\nimport { gsap } from 'gsap';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { SplitText as GSAPSplitText } from 'gsap/SplitText';\nimport { useGSAP } from '@gsap/react';\nimport { type JSX } from 'react';\n\ngsap.registerPlugin(ScrollTrigger, GSAPSplitText);\n\nexport interface ShuffleProps {\n text: string;\n className?: string;\n style?: React.CSSProperties;\n shuffleDirection?: 'left' | 'right' | 'up' | 'down';\n duration?: number;\n maxDelay?: number;\n ease?: string | ((t: number) => number);\n threshold?: number;\n rootMargin?: string;\n tag?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span';\n textAlign?: React.CSSProperties['textAlign'];\n onShuffleComplete?: () => void;\n shuffleTimes?: number;\n animationMode?: 'random' | 'evenodd';\n loop?: boolean;\n loopDelay?: number;\n stagger?: number;\n scrambleCharset?: string;\n colorFrom?: string;\n colorTo?: string;\n triggerOnce?: boolean;\n respectReducedMotion?: boolean;\n triggerOnHover?: boolean;\n}\n\nconst Shuffle: React.FC = ({\n text,\n className = '',\n style = {},\n shuffleDirection = 'right',\n duration = 0.35,\n maxDelay = 0,\n ease = 'power3.out',\n threshold = 0.1,\n rootMargin = '-100px',\n tag = 'p',\n textAlign = 'center',\n onShuffleComplete,\n shuffleTimes = 1,\n animationMode = 'evenodd',\n loop = false,\n loopDelay = 0,\n stagger = 0.03,\n scrambleCharset = '',\n colorFrom,\n colorTo,\n triggerOnce = true,\n respectReducedMotion = true,\n triggerOnHover = true\n}) => {\n const ref = useRef(null);\n const [fontsLoaded, setFontsLoaded] = useState(false);\n const [ready, setReady] = useState(false);\n\n const splitRef = useRef(null);\n const wrappersRef = useRef([]);\n const tlRef = useRef(null);\n const playingRef = useRef(false);\n const hoverHandlerRef = useRef<((e: Event) => void) | null>(null);\n\n useEffect(() => {\n if ('fonts' in document) {\n if (document.fonts.status === 'loaded') setFontsLoaded(true);\n else document.fonts.ready.then(() => setFontsLoaded(true));\n } else setFontsLoaded(true);\n }, []);\n\n const scrollTriggerStart = useMemo(() => {\n const startPct = (1 - threshold) * 100;\n const mm = /^(-?\\d+(?:\\.\\d+)?)(px|em|rem|%)?$/.exec(rootMargin || '');\n const mv = mm ? parseFloat(mm[1]) : 0;\n const mu = mm ? mm[2] || 'px' : 'px';\n const sign = mv === 0 ? '' : mv < 0 ? `-=${Math.abs(mv)}${mu}` : `+=${mv}${mu}`;\n return `top ${startPct}%${sign}`;\n }, [threshold, rootMargin]);\n\n useGSAP(\n () => {\n if (!ref.current || !text || !fontsLoaded) return;\n if (respectReducedMotion && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {\n onShuffleComplete?.();\n return;\n }\n\n const el = ref.current as HTMLElement;\n const start = scrollTriggerStart;\n\n const removeHover = () => {\n if (hoverHandlerRef.current && ref.current) {\n ref.current.removeEventListener('mouseenter', hoverHandlerRef.current);\n hoverHandlerRef.current = null;\n }\n };\n\n const teardown = () => {\n if (tlRef.current) {\n tlRef.current.kill();\n tlRef.current = null;\n }\n if (wrappersRef.current.length) {\n wrappersRef.current.forEach(wrap => {\n const inner = wrap.firstElementChild as HTMLElement | null;\n const orig = inner?.querySelector('[data-orig=\"1\"]') as HTMLElement | null;\n if (orig && wrap.parentNode) wrap.parentNode.replaceChild(orig, wrap);\n });\n wrappersRef.current = [];\n }\n try {\n splitRef.current?.revert();\n } catch {}\n splitRef.current = null;\n playingRef.current = false;\n };\n\n const build = () => {\n teardown();\n\n const computedFont = getComputedStyle(el).fontFamily;\n\n splitRef.current = new GSAPSplitText(el, {\n type: 'chars',\n charsClass: 'shuffle-char',\n wordsClass: 'shuffle-word',\n linesClass: 'shuffle-line',\n smartWrap: true,\n reduceWhiteSpace: false\n });\n\n const chars = (splitRef.current.chars || []) as HTMLElement[];\n wrappersRef.current = [];\n\n const rolls = Math.max(1, Math.floor(shuffleTimes));\n const rand = (set: string) => set.charAt(Math.floor(Math.random() * set.length)) || '';\n const isVertical = shuffleDirection === 'up' || shuffleDirection === 'down';\n const metricsContext = isVertical ? document.createElement('canvas').getContext('2d') : null;\n\n const measureVerticalCell = (node: HTMLElement, lineBoxHeight: number) => {\n const computed = window.getComputedStyle(node);\n let fontHeight = 0;\n\n if (metricsContext) {\n metricsContext.font = [\n computed.fontStyle,\n computed.fontVariant,\n computed.fontWeight,\n computed.fontSize,\n computed.fontFamily\n ].join(' ');\n\n const sample = `${node.textContent || 'M'}${scrambleCharset}`;\n const metrics = metricsContext.measureText(sample);\n const ascent = metrics.fontBoundingBoxAscent;\n const descent = metrics.fontBoundingBoxDescent;\n if (Number.isFinite(ascent) && Number.isFinite(descent)) fontHeight = ascent + descent;\n }\n\n if (!fontHeight) {\n const probe = node.cloneNode(true) as HTMLElement;\n probe.textContent = `${node.textContent || 'M'}${scrambleCharset}`;\n Object.assign(probe.style, {\n position: 'absolute',\n visibility: 'hidden',\n pointerEvents: 'none',\n width: 'auto',\n height: 'auto',\n whiteSpace: 'nowrap',\n lineHeight: 'normal',\n fontFamily: computed.fontFamily,\n fontSize: computed.fontSize,\n fontStyle: computed.fontStyle,\n fontVariant: computed.fontVariant,\n fontWeight: computed.fontWeight,\n fontStretch: computed.fontStretch\n });\n document.body.appendChild(probe);\n fontHeight = probe.getBoundingClientRect().height;\n probe.remove();\n }\n\n const overflow = Math.max(0, Math.ceil(fontHeight - lineBoxHeight));\n const padTop = Math.floor(overflow / 2);\n return { cellHeight: lineBoxHeight + overflow, padTop, padBottom: overflow - padTop };\n };\n\n chars.forEach(ch => {\n const parent = ch.parentElement;\n if (!parent) return;\n\n const w = ch.getBoundingClientRect().width;\n const h = ch.getBoundingClientRect().height;\n if (!w) return;\n\n const { cellHeight, padTop, padBottom } = isVertical\n ? measureVerticalCell(ch, h)\n : { cellHeight: h, padTop: 0, padBottom: 0 };\n\n const wrap = document.createElement('span');\n wrap.className = 'inline-block overflow-hidden text-left';\n Object.assign(wrap.style, {\n width: w + 'px',\n height: isVertical ? cellHeight + 'px' : 'auto',\n marginTop: isVertical ? -padTop + 'px' : '0',\n marginBottom: isVertical ? -padBottom + 'px' : '0',\n verticalAlign: 'bottom'\n });\n\n const inner = document.createElement('span');\n inner.className =\n 'inline-block will-change-transform origin-left transform-gpu ' +\n (isVertical ? 'whitespace-normal' : 'whitespace-nowrap');\n\n parent.insertBefore(wrap, ch);\n wrap.appendChild(inner);\n\n const firstOrig = ch.cloneNode(true) as HTMLElement;\n firstOrig.className = 'text-left ' + (isVertical ? 'flex' : 'inline-block');\n Object.assign(firstOrig.style, {\n alignItems: isVertical ? 'center' : '',\n justifyContent: isVertical ? 'center' : '',\n height: isVertical ? cellHeight + 'px' : '',\n lineHeight: isVertical ? h + 'px' : '',\n width: w + 'px',\n fontFamily: computedFont\n });\n\n ch.setAttribute('data-orig', '1');\n ch.className = 'text-left ' + (isVertical ? 'flex' : 'inline-block');\n Object.assign(ch.style, {\n alignItems: isVertical ? 'center' : '',\n justifyContent: isVertical ? 'center' : '',\n height: isVertical ? cellHeight + 'px' : '',\n lineHeight: isVertical ? h + 'px' : '',\n width: w + 'px',\n fontFamily: computedFont\n });\n\n inner.appendChild(firstOrig);\n for (let k = 0; k < rolls; k++) {\n const c = ch.cloneNode(true) as HTMLElement;\n if (scrambleCharset) c.textContent = rand(scrambleCharset);\n c.className = 'text-left ' + (isVertical ? 'flex' : 'inline-block');\n Object.assign(c.style, {\n alignItems: isVertical ? 'center' : '',\n justifyContent: isVertical ? 'center' : '',\n height: isVertical ? cellHeight + 'px' : '',\n lineHeight: isVertical ? h + 'px' : '',\n width: w + 'px',\n fontFamily: computedFont\n });\n inner.appendChild(c);\n }\n inner.appendChild(ch);\n\n const steps = rolls + 1;\n\n if (shuffleDirection === 'right' || shuffleDirection === 'down') {\n const firstCopy = inner.firstElementChild as HTMLElement | null;\n const real = inner.lastElementChild as HTMLElement | null;\n if (real) inner.insertBefore(real, inner.firstChild);\n if (firstCopy) inner.appendChild(firstCopy);\n }\n\n let startX = 0;\n let finalX = 0;\n let startY = 0;\n let finalY = 0;\n\n if (shuffleDirection === 'right') {\n startX = -steps * w;\n finalX = 0;\n } else if (shuffleDirection === 'left') {\n startX = 0;\n finalX = -steps * w;\n } else if (shuffleDirection === 'down') {\n startY = -steps * cellHeight;\n finalY = 0;\n } else if (shuffleDirection === 'up') {\n startY = 0;\n finalY = -steps * cellHeight;\n }\n\n if (shuffleDirection === 'left' || shuffleDirection === 'right') {\n gsap.set(inner, { x: startX, y: 0, force3D: true });\n inner.setAttribute('data-start-x', String(startX));\n inner.setAttribute('data-final-x', String(finalX));\n } else {\n gsap.set(inner, { x: 0, y: startY, force3D: true });\n inner.setAttribute('data-start-y', String(startY));\n inner.setAttribute('data-final-y', String(finalY));\n }\n\n if (colorFrom) (inner.style as any).color = colorFrom;\n wrappersRef.current.push(wrap);\n });\n };\n\n const inners = () => wrappersRef.current.map(w => w.firstElementChild as HTMLElement);\n\n const randomizeScrambles = () => {\n if (!scrambleCharset) return;\n wrappersRef.current.forEach(w => {\n const strip = w.firstElementChild as HTMLElement;\n if (!strip) return;\n const kids = Array.from(strip.children) as HTMLElement[];\n for (let i = 1; i < kids.length - 1; i++) {\n kids[i].textContent = scrambleCharset.charAt(Math.floor(Math.random() * scrambleCharset.length));\n }\n });\n };\n\n const cleanupToStill = () => {\n wrappersRef.current.forEach(w => {\n const strip = w.firstElementChild as HTMLElement;\n if (!strip) return;\n const real = strip.querySelector('[data-orig=\"1\"]') as HTMLElement | null;\n if (!real) return;\n strip.replaceChildren(real);\n strip.style.transform = 'none';\n strip.style.willChange = 'auto';\n });\n };\n\n const play = () => {\n const strips = inners();\n if (!strips.length) return;\n\n playingRef.current = true;\n const isVertical = shuffleDirection === 'up' || shuffleDirection === 'down';\n\n const tl = gsap.timeline({\n smoothChildTiming: true,\n repeat: loop ? -1 : 0,\n repeatDelay: loop ? loopDelay : 0,\n onRepeat: () => {\n if (scrambleCharset) randomizeScrambles();\n if (isVertical) {\n gsap.set(strips, { y: (i, t: HTMLElement) => parseFloat(t.getAttribute('data-start-y') || '0') });\n } else {\n gsap.set(strips, { x: (i, t: HTMLElement) => parseFloat(t.getAttribute('data-start-x') || '0') });\n }\n onShuffleComplete?.();\n },\n onComplete: () => {\n playingRef.current = false;\n if (!loop) {\n cleanupToStill();\n if (colorTo) gsap.set(strips, { color: colorTo });\n onShuffleComplete?.();\n armHover();\n }\n }\n });\n\n const addTween = (targets: HTMLElement[], at: number) => {\n const vars: any = {\n duration,\n ease,\n force3D: true,\n stagger: animationMode === 'evenodd' ? stagger : 0\n };\n if (isVertical) {\n vars.y = (i: number, t: HTMLElement) => parseFloat(t.getAttribute('data-final-y') || '0');\n } else {\n vars.x = (i: number, t: HTMLElement) => parseFloat(t.getAttribute('data-final-x') || '0');\n }\n\n tl.to(targets, vars, at);\n\n if (colorFrom && colorTo) tl.to(targets, { color: colorTo, duration, ease }, at);\n };\n\n if (animationMode === 'evenodd') {\n const odd = strips.filter((_, i) => i % 2 === 1);\n const even = strips.filter((_, i) => i % 2 === 0);\n const oddTotal = duration + Math.max(0, odd.length - 1) * stagger;\n const evenStart = odd.length ? oddTotal * 0.7 : 0;\n if (odd.length) addTween(odd, 0);\n if (even.length) addTween(even, evenStart);\n } else {\n strips.forEach(strip => {\n const d = Math.random() * maxDelay;\n const vars: any = {\n duration,\n ease,\n force3D: true\n };\n if (isVertical) {\n vars.y = parseFloat(strip.getAttribute('data-final-y') || '0');\n } else {\n vars.x = parseFloat(strip.getAttribute('data-final-x') || '0');\n }\n tl.to(strip, vars, d);\n if (colorFrom && colorTo) tl.fromTo(strip, { color: colorFrom }, { color: colorTo, duration, ease }, d);\n });\n }\n\n tlRef.current = tl;\n };\n\n const armHover = () => {\n if (!triggerOnHover || !ref.current) return;\n removeHover();\n const handler = () => {\n if (playingRef.current) return;\n build();\n if (scrambleCharset) randomizeScrambles();\n play();\n };\n hoverHandlerRef.current = handler;\n ref.current.addEventListener('mouseenter', handler);\n };\n\n const create = () => {\n build();\n if (scrambleCharset) randomizeScrambles();\n play();\n armHover();\n setReady(true);\n };\n\n const st = ScrollTrigger.create({\n trigger: el,\n start,\n once: triggerOnce,\n onEnter: create\n });\n\n return () => {\n st.kill();\n removeHover();\n teardown();\n setReady(false);\n };\n },\n {\n dependencies: [\n text,\n duration,\n maxDelay,\n ease,\n scrollTriggerStart,\n fontsLoaded,\n shuffleDirection,\n shuffleTimes,\n animationMode,\n loop,\n loopDelay,\n stagger,\n scrambleCharset,\n colorFrom,\n colorTo,\n triggerOnce,\n respectReducedMotion,\n triggerOnHover,\n onShuffleComplete\n ],\n scope: ref\n }\n );\n\n const baseTw = 'inline-block whitespace-normal break-words will-change-transform uppercase text-2xl leading-none';\n const userHasFont = useMemo(() => className && /font[-[]/i.test(className), [className]);\n\n const fallbackFont = useMemo(\n () => (userHasFont ? {} : { fontFamily: `'Press Start 2P', sans-serif` }),\n [userHasFont]\n );\n\n const commonStyle = useMemo(\n () => ({\n textAlign,\n ...fallbackFont,\n ...style\n }),\n [textAlign, fallbackFont, style]\n );\n\n const classes = useMemo(\n () => `${baseTw} ${ready ? 'visible' : 'invisible'} ${className}`.trim(),\n [baseTw, ready, className]\n );\n const Tag = (tag || 'p') as keyof JSX.IntrinsicElements;\n\n return React.createElement(Tag, { ref: ref as any, className: classes, style: commonStyle }, text);\n};\n\nexport default Shuffle;\n" } ], "registryDependencies": [], diff --git a/public/r/WarpText-JS-CSS.json b/public/r/WarpText-JS-CSS.json index 52395da9d..dd591c8bf 100644 --- a/public/r/WarpText-JS-CSS.json +++ b/public/r/WarpText-JS-CSS.json @@ -14,7 +14,7 @@ { "type": "registry:component", "path": "WarpText.jsx", - "content": "import { useEffect, useRef } from 'react';\nimport { Renderer, Program, Mesh, Triangle, Texture } from 'ogl';\nimport './WarpText.css';\n\nconst vertex = `#version 300 es\nin vec2 position;\nin vec2 uv;\nout vec2 vUv;\nvoid main() {\n vUv = uv;\n gl_Position = vec4(position, 0.0, 1.0);\n}\n`;\n\nconst fragment = `#version 300 es\nprecision highp float;\n\nuniform sampler2D uTextTexture;\nuniform vec2 uResolution;\nuniform vec2 uPointer;\nuniform float uPointerActive;\nuniform float uTime;\nuniform float uWarpStrength;\nuniform float uWarpScale;\nuniform float uSpeed;\nuniform float uPointerInfluence;\nuniform float uPointerStrength;\nuniform float uRefraction;\nuniform float uRipple;\nuniform float uMotion;\n\nin vec2 vUv;\nout vec4 fragColor;\n\nfloat hash(vec2 p) {\n p = fract(p * vec2(123.34, 456.21));\n p += dot(p, p + 45.32);\n return fract(p.x * p.y);\n}\n\nfloat noise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n\n float a = hash(i);\n float b = hash(i + vec2(1.0, 0.0));\n float c = hash(i + vec2(0.0, 1.0));\n float d = hash(i + vec2(1.0, 1.0));\n\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfloat fbm(vec2 p) {\n float value = 0.0;\n float amplitude = 0.5;\n for (int i = 0; i < 4; i++) {\n value += amplitude * noise(p);\n p *= 2.02;\n amplitude *= 0.5;\n }\n return value;\n}\n\nvec4 sampleText(vec2 uv) {\n if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {\n return vec4(0.0);\n }\n return texture(uTextTexture, uv);\n}\n\nvoid main() {\n vec2 uv = vUv;\n float aspect = uResolution.x / max(uResolution.y, 1.0);\n float time = uTime * uSpeed;\n float scale = max(uWarpScale, 0.001);\n\n vec2 drift = vec2(time * 0.055, -time * 0.045);\n float n1 = fbm(uv * scale * 3.1 + drift);\n float n2 = fbm((uv + 19.17) * scale * 3.4 - drift.yx);\n vec2 ambient = (vec2(n1, n2) - 0.5) * uWarpStrength * 0.045 * uMotion;\n\n vec2 pointerDelta = uv - uPointer;\n vec2 aspectDelta = vec2(pointerDelta.x * aspect, pointerDelta.y);\n float dist = length(aspectDelta);\n float radius = max(uPointerInfluence, 0.001);\n float t = clamp(dist / radius, 0.0, 1.0);\n float lens = smoothstep(radius, 0.0, dist) * uPointerActive;\n float bulge = t * (1.0 - t) * (1.0 - t) * 6.75 * uPointerActive;\n vec2 dir = dist > 0.0001 ? vec2(aspectDelta.x / aspect, aspectDelta.y) / dist : vec2(0.0);\n\n float rippleWave = sin(dist * 28.0 - time * 4.2) * 0.5 + 0.5;\n float rippleRing = (rippleWave - 0.5) * uRipple;\n vec2 pointerWarp = -dir * bulge * uPointerStrength * 0.045;\n pointerWarp += dir * rippleRing * bulge * uPointerStrength * 0.016;\n\n vec2 displaced = uv + ambient + pointerWarp;\n vec2 splitDir = ambient + pointerWarp;\n float splitLen = length(splitDir);\n splitDir = splitLen > 0.00001 ? splitDir / splitLen : vec2(0.7071, 0.7071);\n vec2 split = splitDir * uRefraction * 0.16 * (0.35 + lens * 1.65);\n\n vec4 base = sampleText(displaced);\n float r = sampleText(displaced + split).r;\n float g = base.g;\n float b = sampleText(displaced - split).b;\n float a = max(max(sampleText(displaced + split).a, base.a), sampleText(displaced - split).a);\n\n vec3 color = vec3(r, g, b) + lens * base.a * 0.055;\n fragColor = vec4(color, a);\n}\n`;\n\nconst getFontValue = value => (typeof value === 'number' ? `${value}px` : value);\n\nconst measureLine = (ctx, line, letterSpacing) => {\n const chars = Array.from(line);\n const textWidth = chars.reduce((width, char) => width + ctx.measureText(char).width, 0);\n return textWidth + Math.max(0, chars.length - 1) * letterSpacing;\n};\n\nconst drawLine = (ctx, line, x, y, letterSpacing) => {\n const chars = Array.from(line);\n let cursor = x - measureLine(ctx, line, letterSpacing) / 2;\n\n chars.forEach((char, index) => {\n ctx.fillText(char, cursor, y);\n cursor += ctx.measureText(char).width + (index === chars.length - 1 ? 0 : letterSpacing);\n });\n};\n\nconst buildTextCanvas = ({ container, width, height, dpr, props }) => {\n const canvas = document.createElement('canvas');\n canvas.width = Math.max(1, Math.floor(width * dpr));\n canvas.height = Math.max(1, Math.floor(height * dpr));\n\n const ctx = canvas.getContext('2d');\n if (!ctx) return canvas;\n\n const probe = document.createElement('span');\n probe.textContent = props.text;\n Object.assign(probe.style, {\n position: 'absolute',\n visibility: 'hidden',\n pointerEvents: 'none',\n whiteSpace: 'pre',\n inset: '0 auto auto 0',\n fontFamily: props.fontFamily,\n fontSize: getFontValue(props.fontSize),\n fontWeight: String(props.fontWeight),\n letterSpacing: getFontValue(props.letterSpacing),\n lineHeight: typeof props.lineHeight === 'number' ? String(props.lineHeight) : props.lineHeight\n });\n container.appendChild(probe);\n const computed = window.getComputedStyle(probe);\n let fontSizePx = parseFloat(computed.fontSize) || 96;\n const fontFamily = computed.fontFamily || 'sans-serif';\n const fontWeight = computed.fontWeight || String(props.fontWeight);\n let letterSpacing = computed.letterSpacing === 'normal' ? 0 : parseFloat(computed.letterSpacing) || 0;\n let lineHeight = parseFloat(computed.lineHeight);\n if (!Number.isFinite(lineHeight)) {\n lineHeight = fontSizePx * (typeof props.lineHeight === 'number' ? props.lineHeight : 0.92);\n }\n probe.remove();\n\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n ctx.clearRect(0, 0, width, height);\n ctx.textAlign = 'left';\n ctx.textBaseline = 'middle';\n ctx.fillStyle = props.color;\n ctx.imageSmoothingEnabled = true;\n ctx.imageSmoothingQuality = 'high';\n\n const lines = String(props.text || '').split('\\n');\n const applyFont = () => {\n ctx.font = `${fontWeight} ${fontSizePx}px ${fontFamily}`;\n };\n applyFont();\n\n const maxWidth = width * 0.86;\n const maxHeight = height * 0.78;\n const widest = Math.max(...lines.map(line => measureLine(ctx, line, letterSpacing)), 1);\n const blockHeight = Math.max(lineHeight * lines.length, 1);\n const fit = Math.min(1, maxWidth / widest, maxHeight / blockHeight);\n\n if (fit < 1) {\n fontSizePx *= fit;\n letterSpacing *= fit;\n lineHeight *= fit;\n applyFont();\n }\n\n const startY = height / 2 - (lineHeight * (lines.length - 1)) / 2;\n lines.forEach((line, index) => drawLine(ctx, line, width / 2, startY + index * lineHeight, letterSpacing));\n\n return canvas;\n};\n\nconst syncUniforms = (program, props) => {\n const uniforms = program.uniforms;\n uniforms.uWarpStrength.value = props.warpStrength;\n uniforms.uWarpScale.value = props.warpScale;\n uniforms.uSpeed.value = props.speed;\n uniforms.uPointerInfluence.value = props.pointerInfluence;\n uniforms.uPointerStrength.value = props.pointerStrength;\n uniforms.uRefraction.value = props.refraction;\n uniforms.uRipple.value = props.ripple ? 1 : 0;\n};\n\nconst WarpText = ({\n text = 'Bend the moment',\n color = '#f8f5ff',\n warpStrength = 0.08,\n warpScale = 1.7,\n speed = 0.55,\n pointerInfluence = 0.42,\n pointerStrength = 0.38,\n refraction = 0.018,\n ripple = true,\n fontSize = 'clamp(3rem, 10vw, 9rem)',\n fontWeight = 800,\n fontFamily = 'inherit',\n letterSpacing = '-0.06em',\n lineHeight = 0.9,\n className = '',\n style\n}) => {\n const containerRef = useRef(null);\n const propsRef = useRef({\n text,\n color,\n fontSize,\n fontWeight,\n fontFamily,\n letterSpacing,\n lineHeight,\n warpStrength,\n warpScale,\n speed,\n pointerInfluence,\n pointerStrength,\n refraction,\n ripple\n });\n const contextRef = useRef(null);\n\n useEffect(() => {\n propsRef.current = {\n text,\n color,\n fontSize,\n fontWeight,\n fontFamily,\n letterSpacing,\n lineHeight,\n warpStrength,\n warpScale,\n speed,\n pointerInfluence,\n pointerStrength,\n refraction,\n ripple\n };\n\n if (contextRef.current) {\n syncUniforms(contextRef.current.program, propsRef.current);\n contextRef.current.rasterize();\n }\n }, [\n text,\n color,\n fontSize,\n fontWeight,\n fontFamily,\n letterSpacing,\n lineHeight,\n warpStrength,\n warpScale,\n speed,\n pointerInfluence,\n pointerStrength,\n refraction,\n ripple\n ]);\n\n useEffect(() => {\n const container = containerRef.current;\n if (!container || typeof window === 'undefined') return undefined;\n\n let renderer;\n let gl;\n let program;\n let geometry;\n let mesh;\n let texture;\n let resizeObserver;\n let intersectionObserver;\n let raf = 0;\n let disposed = false;\n let contextLost = false;\n let visible = true;\n let pageVisible = !document.hidden;\n let reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;\n let rasterVersion = 0;\n\n const pointer = { x: 0.5, y: 0.5, tx: 0.5, ty: 0.5, active: 0, activeTarget: 0 };\n const startTime = performance.now();\n\n try {\n renderer = new Renderer({\n webgl: 2,\n alpha: true,\n premultipliedAlpha: false,\n antialias: true,\n dpr: Math.min(window.devicePixelRatio || 1, 2)\n });\n gl = renderer.gl;\n } catch (error) {\n console.warn('WarpText: WebGL could not be initialized.', error);\n return undefined;\n }\n\n gl.clearColor(0, 0, 0, 0);\n const canvas = gl.canvas;\n canvas.style.position = 'absolute';\n canvas.style.inset = '0';\n canvas.style.width = '100%';\n canvas.style.height = '100%';\n canvas.style.display = 'block';\n canvas.setAttribute('aria-hidden', 'true');\n container.appendChild(canvas);\n\n texture = new Texture(gl, {\n generateMipmaps: false,\n minFilter: gl.LINEAR,\n magFilter: gl.LINEAR,\n wrapS: gl.CLAMP_TO_EDGE,\n wrapT: gl.CLAMP_TO_EDGE\n });\n\n geometry = new Triangle(gl);\n program = new Program(gl, {\n vertex,\n fragment,\n transparent: true,\n depthTest: false,\n depthWrite: false,\n uniforms: {\n uTextTexture: { value: texture },\n uResolution: { value: new Float32Array([1, 1]) },\n uPointer: { value: new Float32Array([0.5, 0.5]) },\n uPointerActive: { value: 0 },\n uTime: { value: 0 },\n uWarpStrength: { value: propsRef.current.warpStrength },\n uWarpScale: { value: propsRef.current.warpScale },\n uSpeed: { value: propsRef.current.speed },\n uPointerInfluence: { value: propsRef.current.pointerInfluence },\n uPointerStrength: { value: propsRef.current.pointerStrength },\n uRefraction: { value: propsRef.current.refraction },\n uRipple: { value: propsRef.current.ripple ? 1 : 0 },\n uMotion: { value: reduceMotion ? 0 : 1 }\n }\n });\n mesh = new Mesh(gl, { geometry, program });\n\n const renderOnce = () => {\n if (disposed || contextLost) return;\n renderer.render({ scene: mesh });\n };\n\n const rasterize = async () => {\n const version = ++rasterVersion;\n if (document.fonts?.ready) {\n try {\n await document.fonts.ready;\n } catch {}\n }\n if (disposed || contextLost || version !== rasterVersion) return;\n\n const rect = container.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n const textCanvas = buildTextCanvas({\n container,\n width: rect.width,\n height: rect.height,\n dpr,\n props: propsRef.current\n });\n texture.image = textCanvas;\n texture.needsUpdate = true;\n renderOnce();\n };\n\n const resize = () => {\n if (disposed || contextLost) return;\n const rect = container.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n\n renderer.dpr = Math.min(window.devicePixelRatio || 1, 2);\n renderer.setSize(rect.width, rect.height);\n program.uniforms.uResolution.value[0] = gl.drawingBufferWidth;\n program.uniforms.uResolution.value[1] = gl.drawingBufferHeight;\n rasterize();\n };\n\n const onPointerMove = event => {\n if (event.pointerType === 'touch') return;\n const rect = canvas.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n pointer.tx = (event.clientX - rect.left) / rect.width;\n pointer.ty = 1 - (event.clientY - rect.top) / rect.height;\n pointer.activeTarget = 1;\n };\n\n const onPointerLeave = () => {\n pointer.activeTarget = 0;\n };\n\n const onContextLost = event => {\n event.preventDefault();\n contextLost = true;\n if (raf) cancelAnimationFrame(raf);\n raf = 0;\n };\n\n const onVisibility = () => {\n pageVisible = !document.hidden;\n if (pageVisible && visible && !raf) raf = requestAnimationFrame(loop);\n if (!pageVisible && raf) {\n cancelAnimationFrame(raf);\n raf = 0;\n }\n };\n\n const mediaQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)');\n const onReducedMotion = event => {\n reduceMotion = event.matches;\n program.uniforms.uMotion.value = reduceMotion ? 0 : 1;\n renderOnce();\n };\n\n const loop = now => {\n if (disposed || contextLost) return;\n\n const elapsed = (now - startTime) * 0.001;\n const idleX = 0.5 + Math.sin(elapsed * 0.33) * 0.12;\n const idleY = 0.5 + Math.cos(elapsed * 0.27) * 0.1;\n const targetX = pointer.activeTarget > 0 ? pointer.tx : idleX;\n const targetY = pointer.activeTarget > 0 ? pointer.ty : idleY;\n const damping = pointer.activeTarget > 0 ? 0.12 : 0.035;\n\n pointer.x += (targetX - pointer.x) * damping;\n pointer.y += (targetY - pointer.y) * damping;\n pointer.active += ((pointer.activeTarget > 0 ? 1 : 0.18) - pointer.active) * 0.06;\n\n program.uniforms.uPointer.value[0] = pointer.x;\n program.uniforms.uPointer.value[1] = pointer.y;\n program.uniforms.uPointerActive.value = reduceMotion ? pointer.active * 0.35 : pointer.active;\n program.uniforms.uTime.value = reduceMotion ? 0 : elapsed;\n\n renderOnce();\n raf = requestAnimationFrame(loop);\n };\n\n resizeObserver = new ResizeObserver(resize);\n resizeObserver.observe(container);\n\n intersectionObserver = new IntersectionObserver(\n ([entry]) => {\n visible = entry.isIntersecting;\n if (visible && pageVisible && !raf) raf = requestAnimationFrame(loop);\n if (!visible && raf) {\n cancelAnimationFrame(raf);\n raf = 0;\n }\n },\n { threshold: 0 }\n );\n intersectionObserver.observe(container);\n\n canvas.addEventListener('pointermove', onPointerMove);\n canvas.addEventListener('pointerleave', onPointerLeave);\n canvas.addEventListener('webglcontextlost', onContextLost, false);\n document.addEventListener('visibilitychange', onVisibility);\n mediaQuery?.addEventListener('change', onReducedMotion);\n\n syncUniforms(program, propsRef.current);\n contextRef.current = { program, rasterize };\n resize();\n raf = requestAnimationFrame(loop);\n\n return () => {\n disposed = true;\n contextRef.current = null;\n if (raf) cancelAnimationFrame(raf);\n resizeObserver?.disconnect();\n intersectionObserver?.disconnect();\n canvas.removeEventListener('pointermove', onPointerMove);\n canvas.removeEventListener('pointerleave', onPointerLeave);\n canvas.removeEventListener('webglcontextlost', onContextLost);\n document.removeEventListener('visibilitychange', onVisibility);\n mediaQuery?.removeEventListener('change', onReducedMotion);\n\n if (!contextLost) {\n try {\n if (texture?.texture) gl.deleteTexture(texture.texture);\n geometry?.remove?.();\n program?.remove?.();\n gl.getExtension('WEBGL_lose_context')?.loseContext();\n } catch {}\n }\n\n if (canvas.parentNode === container) container.removeChild(canvas);\n };\n }, []);\n\n return (\n
\n );\n};\n\nexport default WarpText;\n" + "content": "import { useEffect, useRef } from 'react';\nimport { Renderer, Program, Mesh, Triangle, Texture } from 'ogl';\nimport './WarpText.css';\n\nconst vertex = `#version 300 es\nin vec2 position;\nin vec2 uv;\nout vec2 vUv;\nvoid main() {\n vUv = uv;\n gl_Position = vec4(position, 0.0, 1.0);\n}\n`;\n\nconst fragment = `#version 300 es\nprecision highp float;\n\nuniform sampler2D uTextTexture;\nuniform vec2 uResolution;\nuniform vec2 uPointer;\nuniform float uPointerActive;\nuniform float uTime;\nuniform float uWarpStrength;\nuniform float uWarpScale;\nuniform float uSpeed;\nuniform float uPointerInfluence;\nuniform float uPointerStrength;\nuniform float uRefraction;\nuniform float uRipple;\nuniform float uMotion;\n\nin vec2 vUv;\nout vec4 fragColor;\n\nfloat hash(vec2 p) {\n p = fract(p * vec2(123.34, 456.21));\n p += dot(p, p + 45.32);\n return fract(p.x * p.y);\n}\n\nfloat noise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n\n float a = hash(i);\n float b = hash(i + vec2(1.0, 0.0));\n float c = hash(i + vec2(0.0, 1.0));\n float d = hash(i + vec2(1.0, 1.0));\n\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfloat fbm(vec2 p) {\n float value = 0.0;\n float amplitude = 0.5;\n for (int i = 0; i < 4; i++) {\n value += amplitude * noise(p);\n p *= 2.02;\n amplitude *= 0.5;\n }\n return value;\n}\n\nvec4 sampleText(vec2 uv) {\n if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {\n return vec4(0.0);\n }\n return texture(uTextTexture, uv);\n}\n\nvoid main() {\n vec2 uv = vUv;\n float aspect = uResolution.x / max(uResolution.y, 1.0);\n float time = uTime * uSpeed;\n float scale = max(uWarpScale, 0.001);\n\n vec2 drift = vec2(time * 0.055, -time * 0.045);\n float n1 = fbm(uv * scale * 3.1 + drift);\n float n2 = fbm((uv + 19.17) * scale * 3.4 - drift.yx);\n vec2 ambient = (vec2(n1, n2) - 0.5) * uWarpStrength * 0.045 * uMotion;\n\n vec2 pointerDelta = uv - uPointer;\n vec2 aspectDelta = vec2(pointerDelta.x * aspect, pointerDelta.y);\n float dist = length(aspectDelta);\n float radius = max(uPointerInfluence, 0.001);\n float t = clamp(dist / radius, 0.0, 1.0);\n float lens = smoothstep(radius, 0.0, dist) * uPointerActive;\n float bulge = t * (1.0 - t) * (1.0 - t) * 6.75 * uPointerActive;\n vec2 dir = dist > 0.0001 ? vec2(aspectDelta.x / aspect, aspectDelta.y) / dist : vec2(0.0);\n\n float rippleWave = sin(dist * 28.0 - time * 4.2) * 0.5 + 0.5;\n float rippleRing = (rippleWave - 0.5) * uRipple;\n vec2 pointerWarp = -dir * bulge * uPointerStrength * 0.045;\n pointerWarp += dir * rippleRing * bulge * uPointerStrength * 0.016;\n\n vec2 displaced = uv + ambient + pointerWarp;\n vec2 splitDir = ambient + pointerWarp;\n float splitLen = length(splitDir);\n splitDir = splitLen > 0.00001 ? splitDir / splitLen : vec2(0.7071, 0.7071);\n vec2 split = splitDir * uRefraction * 0.16 * (0.35 + lens * 1.65);\n\n vec4 base = sampleText(displaced);\n float r = sampleText(displaced + split).r;\n float g = base.g;\n float b = sampleText(displaced - split).b;\n float a = max(max(sampleText(displaced + split).a, base.a), sampleText(displaced - split).a);\n\n vec3 color = vec3(r, g, b) + lens * base.a * 0.055;\n fragColor = vec4(color, a);\n}\n`;\n\nconst getFontValue = value => (typeof value === 'number' ? `${value}px` : value);\n\nconst measureLine = (ctx, line, letterSpacing) => {\n const chars = Array.from(line);\n const textWidth = chars.reduce((width, char) => width + ctx.measureText(char).width, 0);\n return textWidth + Math.max(0, chars.length - 1) * letterSpacing;\n};\n\nconst drawLine = (ctx, line, x, y, letterSpacing) => {\n const chars = Array.from(line);\n let cursor = x - measureLine(ctx, line, letterSpacing) / 2;\n\n chars.forEach((char, index) => {\n ctx.fillText(char, cursor, y);\n cursor += ctx.measureText(char).width + (index === chars.length - 1 ? 0 : letterSpacing);\n });\n};\n\nconst buildTextCanvas = ({ container, width, height, dpr, props }) => {\n const canvas = document.createElement('canvas');\n canvas.width = Math.max(1, Math.floor(width * dpr));\n canvas.height = Math.max(1, Math.floor(height * dpr));\n\n const ctx = canvas.getContext('2d');\n if (!ctx) return canvas;\n\n const probe = document.createElement('span');\n probe.textContent = props.text;\n Object.assign(probe.style, {\n position: 'absolute',\n visibility: 'hidden',\n pointerEvents: 'none',\n whiteSpace: 'pre',\n inset: '0 auto auto 0',\n fontFamily: props.fontFamily,\n fontSize: getFontValue(props.fontSize),\n fontWeight: String(props.fontWeight),\n letterSpacing: getFontValue(props.letterSpacing),\n lineHeight: typeof props.lineHeight === 'number' ? String(props.lineHeight) : props.lineHeight\n });\n container.appendChild(probe);\n const computed = window.getComputedStyle(probe);\n let fontSizePx = parseFloat(computed.fontSize) || 96;\n const fontFamily = computed.fontFamily || 'sans-serif';\n const fontWeight = computed.fontWeight || String(props.fontWeight);\n let letterSpacing = computed.letterSpacing === 'normal' ? 0 : parseFloat(computed.letterSpacing) || 0;\n let lineHeight = parseFloat(computed.lineHeight);\n if (!Number.isFinite(lineHeight)) {\n lineHeight = fontSizePx * (typeof props.lineHeight === 'number' ? props.lineHeight : 0.92);\n }\n probe.remove();\n\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n ctx.clearRect(0, 0, width, height);\n ctx.textAlign = 'left';\n ctx.textBaseline = 'middle';\n ctx.fillStyle = props.color;\n ctx.imageSmoothingEnabled = true;\n ctx.imageSmoothingQuality = 'high';\n\n const lines = String(props.text || '').split('\\n');\n const applyFont = () => {\n ctx.font = `${fontWeight} ${fontSizePx}px ${fontFamily}`;\n };\n applyFont();\n\n const maxWidth = width * 0.86;\n const maxHeight = height * 0.78;\n const widest = Math.max(...lines.map(line => measureLine(ctx, line, letterSpacing)), 1);\n const blockHeight = Math.max(lineHeight * lines.length, 1);\n const fit = Math.min(1, maxWidth / widest, maxHeight / blockHeight);\n\n if (fit < 1) {\n fontSizePx *= fit;\n letterSpacing *= fit;\n lineHeight *= fit;\n applyFont();\n }\n\n const startY = height / 2 - (lineHeight * (lines.length - 1)) / 2;\n lines.forEach((line, index) => drawLine(ctx, line, width / 2, startY + index * lineHeight, letterSpacing));\n\n return canvas;\n};\n\nconst syncUniforms = (program, props) => {\n const uniforms = program.uniforms;\n uniforms.uWarpStrength.value = props.warpStrength;\n uniforms.uWarpScale.value = props.warpScale;\n uniforms.uSpeed.value = props.speed;\n uniforms.uPointerInfluence.value = props.pointerInfluence;\n uniforms.uPointerStrength.value = props.pointerStrength;\n uniforms.uRefraction.value = props.refraction;\n uniforms.uRipple.value = props.ripple ? 1 : 0;\n};\n\nconst WarpText = ({\n text = 'Bend the moment',\n color = '#f8f5ff',\n warpStrength = 0.08,\n warpScale = 1.7,\n speed = 0.55,\n pointerInfluence = 0.42,\n pointerStrength = 0.38,\n refraction = 0.018,\n ripple = true,\n fontSize = 'clamp(3rem, 10vw, 9rem)',\n fontWeight = 800,\n fontFamily = 'inherit',\n letterSpacing = '-0.06em',\n lineHeight = 0.9,\n className = '',\n style\n}) => {\n const containerRef = useRef(null);\n const propsRef = useRef({\n text,\n color,\n fontSize,\n fontWeight,\n fontFamily,\n letterSpacing,\n lineHeight,\n warpStrength,\n warpScale,\n speed,\n pointerInfluence,\n pointerStrength,\n refraction,\n ripple\n });\n const contextRef = useRef(null);\n\n useEffect(() => {\n propsRef.current = {\n text,\n color,\n fontSize,\n fontWeight,\n fontFamily,\n letterSpacing,\n lineHeight,\n warpStrength,\n warpScale,\n speed,\n pointerInfluence,\n pointerStrength,\n refraction,\n ripple\n };\n\n if (contextRef.current) {\n syncUniforms(contextRef.current.program, propsRef.current);\n contextRef.current.rasterize();\n }\n }, [\n text,\n color,\n fontSize,\n fontWeight,\n fontFamily,\n letterSpacing,\n lineHeight,\n warpStrength,\n warpScale,\n speed,\n pointerInfluence,\n pointerStrength,\n refraction,\n ripple\n ]);\n\n useEffect(() => {\n const container = containerRef.current;\n if (!container || typeof window === 'undefined') return undefined;\n\n let renderer;\n let gl;\n let program;\n let geometry;\n let mesh;\n let texture;\n let resizeObserver;\n let intersectionObserver;\n let raf = 0;\n let disposed = false;\n let contextLost = false;\n let visible = true;\n let pageVisible = !document.hidden;\n let reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;\n let rasterVersion = 0;\n\n const pointer = { x: 0.5, y: 0.5, tx: 0.5, ty: 0.5, active: 0, activeTarget: 0 };\n const startTime = performance.now();\n\n try {\n renderer = new Renderer({\n webgl: 2,\n alpha: true,\n premultipliedAlpha: false,\n antialias: true,\n dpr: Math.min(window.devicePixelRatio || 1, 2)\n });\n gl = renderer.gl;\n } catch (error) {\n console.warn('WarpText: WebGL could not be initialized.', error);\n return undefined;\n }\n\n gl.clearColor(0, 0, 0, 0);\n const canvas = gl.canvas;\n canvas.style.position = 'absolute';\n canvas.style.inset = '0';\n canvas.style.width = '100%';\n canvas.style.height = '100%';\n canvas.style.display = 'block';\n canvas.setAttribute('aria-hidden', 'true');\n container.appendChild(canvas);\n\n texture = new Texture(gl, {\n generateMipmaps: false,\n minFilter: gl.LINEAR,\n magFilter: gl.LINEAR,\n wrapS: gl.CLAMP_TO_EDGE,\n wrapT: gl.CLAMP_TO_EDGE\n });\n\n geometry = new Triangle(gl);\n program = new Program(gl, {\n vertex,\n fragment,\n transparent: true,\n depthTest: false,\n depthWrite: false,\n uniforms: {\n uTextTexture: { value: texture },\n uResolution: { value: new Float32Array([1, 1]) },\n uPointer: { value: new Float32Array([0.5, 0.5]) },\n uPointerActive: { value: 0 },\n uTime: { value: 0 },\n uWarpStrength: { value: propsRef.current.warpStrength },\n uWarpScale: { value: propsRef.current.warpScale },\n uSpeed: { value: propsRef.current.speed },\n uPointerInfluence: { value: propsRef.current.pointerInfluence },\n uPointerStrength: { value: propsRef.current.pointerStrength },\n uRefraction: { value: propsRef.current.refraction },\n uRipple: { value: propsRef.current.ripple ? 1 : 0 },\n uMotion: { value: reduceMotion ? 0 : 1 }\n }\n });\n mesh = new Mesh(gl, { geometry, program });\n\n const renderOnce = () => {\n if (disposed || contextLost) return;\n renderer.render({ scene: mesh });\n };\n\n const rasterize = async () => {\n const version = ++rasterVersion;\n if (document.fonts?.ready) {\n try {\n await document.fonts.ready;\n } catch (error) {\n void error;\n }\n }\n if (disposed || contextLost || version !== rasterVersion) return;\n\n const rect = container.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n const textCanvas = buildTextCanvas({\n container,\n width: rect.width,\n height: rect.height,\n dpr,\n props: propsRef.current\n });\n texture.image = textCanvas;\n texture.needsUpdate = true;\n renderOnce();\n };\n\n const resize = () => {\n if (disposed || contextLost) return;\n const rect = container.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n\n renderer.dpr = Math.min(window.devicePixelRatio || 1, 2);\n renderer.setSize(rect.width, rect.height);\n program.uniforms.uResolution.value[0] = gl.drawingBufferWidth;\n program.uniforms.uResolution.value[1] = gl.drawingBufferHeight;\n rasterize();\n };\n\n const onPointerMove = event => {\n if (event.pointerType === 'touch') return;\n const rect = canvas.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n pointer.tx = (event.clientX - rect.left) / rect.width;\n pointer.ty = 1 - (event.clientY - rect.top) / rect.height;\n pointer.activeTarget = 1;\n };\n\n const onPointerLeave = () => {\n pointer.activeTarget = 0;\n };\n\n const onContextLost = event => {\n event.preventDefault();\n contextLost = true;\n if (raf) cancelAnimationFrame(raf);\n raf = 0;\n };\n\n const onVisibility = () => {\n pageVisible = !document.hidden;\n if (pageVisible && visible && !raf) raf = requestAnimationFrame(loop);\n if (!pageVisible && raf) {\n cancelAnimationFrame(raf);\n raf = 0;\n }\n };\n\n const mediaQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)');\n const onReducedMotion = event => {\n reduceMotion = event.matches;\n program.uniforms.uMotion.value = reduceMotion ? 0 : 1;\n renderOnce();\n };\n\n const loop = now => {\n if (disposed || contextLost) return;\n\n const elapsed = (now - startTime) * 0.001;\n const idleX = 0.5 + Math.sin(elapsed * 0.33) * 0.12;\n const idleY = 0.5 + Math.cos(elapsed * 0.27) * 0.1;\n const targetX = pointer.activeTarget > 0 ? pointer.tx : idleX;\n const targetY = pointer.activeTarget > 0 ? pointer.ty : idleY;\n const damping = pointer.activeTarget > 0 ? 0.12 : 0.035;\n\n pointer.x += (targetX - pointer.x) * damping;\n pointer.y += (targetY - pointer.y) * damping;\n pointer.active += ((pointer.activeTarget > 0 ? 1 : 0.18) - pointer.active) * 0.06;\n\n program.uniforms.uPointer.value[0] = pointer.x;\n program.uniforms.uPointer.value[1] = pointer.y;\n program.uniforms.uPointerActive.value = reduceMotion ? pointer.active * 0.35 : pointer.active;\n program.uniforms.uTime.value = reduceMotion ? 0 : elapsed;\n\n renderOnce();\n raf = requestAnimationFrame(loop);\n };\n\n resizeObserver = new ResizeObserver(resize);\n resizeObserver.observe(container);\n\n intersectionObserver = new IntersectionObserver(\n ([entry]) => {\n visible = entry.isIntersecting;\n if (visible && pageVisible && !raf) raf = requestAnimationFrame(loop);\n if (!visible && raf) {\n cancelAnimationFrame(raf);\n raf = 0;\n }\n },\n { threshold: 0 }\n );\n intersectionObserver.observe(container);\n\n canvas.addEventListener('pointermove', onPointerMove);\n canvas.addEventListener('pointerleave', onPointerLeave);\n canvas.addEventListener('webglcontextlost', onContextLost, false);\n document.addEventListener('visibilitychange', onVisibility);\n mediaQuery?.addEventListener('change', onReducedMotion);\n\n syncUniforms(program, propsRef.current);\n contextRef.current = { program, rasterize };\n resize();\n raf = requestAnimationFrame(loop);\n\n return () => {\n disposed = true;\n contextRef.current = null;\n if (raf) cancelAnimationFrame(raf);\n resizeObserver?.disconnect();\n intersectionObserver?.disconnect();\n canvas.removeEventListener('pointermove', onPointerMove);\n canvas.removeEventListener('pointerleave', onPointerLeave);\n canvas.removeEventListener('webglcontextlost', onContextLost);\n document.removeEventListener('visibilitychange', onVisibility);\n mediaQuery?.removeEventListener('change', onReducedMotion);\n\n if (!contextLost) {\n try {\n if (texture?.texture) gl.deleteTexture(texture.texture);\n geometry?.remove?.();\n program?.remove?.();\n gl.getExtension('WEBGL_lose_context')?.loseContext();\n } catch (error) {\n void error;\n }\n }\n\n if (canvas.parentNode === container) container.removeChild(canvas);\n };\n }, []);\n\n return (\n
\n );\n};\n\nexport default WarpText;\n" } ], "registryDependencies": [], diff --git a/public/r/WarpText-JS-TW.json b/public/r/WarpText-JS-TW.json index a31e62854..9550d0307 100644 --- a/public/r/WarpText-JS-TW.json +++ b/public/r/WarpText-JS-TW.json @@ -8,7 +8,7 @@ { "type": "registry:component", "path": "WarpText/WarpText.jsx", - "content": "import { useEffect, useRef } from 'react';\nimport { Renderer, Program, Mesh, Triangle, Texture } from 'ogl';\n\nconst vertex = `#version 300 es\nin vec2 position;\nin vec2 uv;\nout vec2 vUv;\nvoid main() {\n vUv = uv;\n gl_Position = vec4(position, 0.0, 1.0);\n}\n`;\n\nconst fragment = `#version 300 es\nprecision highp float;\n\nuniform sampler2D uTextTexture;\nuniform vec2 uResolution;\nuniform vec2 uPointer;\nuniform float uPointerActive;\nuniform float uTime;\nuniform float uWarpStrength;\nuniform float uWarpScale;\nuniform float uSpeed;\nuniform float uPointerInfluence;\nuniform float uPointerStrength;\nuniform float uRefraction;\nuniform float uRipple;\nuniform float uMotion;\n\nin vec2 vUv;\nout vec4 fragColor;\n\nfloat hash(vec2 p) {\n p = fract(p * vec2(123.34, 456.21));\n p += dot(p, p + 45.32);\n return fract(p.x * p.y);\n}\n\nfloat noise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n\n float a = hash(i);\n float b = hash(i + vec2(1.0, 0.0));\n float c = hash(i + vec2(0.0, 1.0));\n float d = hash(i + vec2(1.0, 1.0));\n\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfloat fbm(vec2 p) {\n float value = 0.0;\n float amplitude = 0.5;\n for (int i = 0; i < 4; i++) {\n value += amplitude * noise(p);\n p *= 2.02;\n amplitude *= 0.5;\n }\n return value;\n}\n\nvec4 sampleText(vec2 uv) {\n if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {\n return vec4(0.0);\n }\n return texture(uTextTexture, uv);\n}\n\nvoid main() {\n vec2 uv = vUv;\n float aspect = uResolution.x / max(uResolution.y, 1.0);\n float time = uTime * uSpeed;\n float scale = max(uWarpScale, 0.001);\n\n vec2 drift = vec2(time * 0.055, -time * 0.045);\n float n1 = fbm(uv * scale * 3.1 + drift);\n float n2 = fbm((uv + 19.17) * scale * 3.4 - drift.yx);\n vec2 ambient = (vec2(n1, n2) - 0.5) * uWarpStrength * 0.045 * uMotion;\n\n vec2 pointerDelta = uv - uPointer;\n vec2 aspectDelta = vec2(pointerDelta.x * aspect, pointerDelta.y);\n float dist = length(aspectDelta);\n float radius = max(uPointerInfluence, 0.001);\n float t = clamp(dist / radius, 0.0, 1.0);\n float lens = smoothstep(radius, 0.0, dist) * uPointerActive;\n float bulge = t * (1.0 - t) * (1.0 - t) * 6.75 * uPointerActive;\n vec2 dir = dist > 0.0001 ? vec2(aspectDelta.x / aspect, aspectDelta.y) / dist : vec2(0.0);\n\n float rippleWave = sin(dist * 28.0 - time * 4.2) * 0.5 + 0.5;\n float rippleRing = (rippleWave - 0.5) * uRipple;\n vec2 pointerWarp = -dir * bulge * uPointerStrength * 0.045;\n pointerWarp += dir * rippleRing * bulge * uPointerStrength * 0.016;\n\n vec2 displaced = uv + ambient + pointerWarp;\n vec2 splitDir = ambient + pointerWarp;\n float splitLen = length(splitDir);\n splitDir = splitLen > 0.00001 ? splitDir / splitLen : vec2(0.7071, 0.7071);\n vec2 split = splitDir * uRefraction * 0.16 * (0.35 + lens * 1.65);\n\n vec4 base = sampleText(displaced);\n float r = sampleText(displaced + split).r;\n float g = base.g;\n float b = sampleText(displaced - split).b;\n float a = max(max(sampleText(displaced + split).a, base.a), sampleText(displaced - split).a);\n\n vec3 color = vec3(r, g, b) + lens * base.a * 0.055;\n fragColor = vec4(color, a);\n}\n`;\n\nconst getFontValue = value => (typeof value === 'number' ? `${value}px` : value);\n\nconst measureLine = (ctx, line, letterSpacing) => {\n const chars = Array.from(line);\n const textWidth = chars.reduce((width, char) => width + ctx.measureText(char).width, 0);\n return textWidth + Math.max(0, chars.length - 1) * letterSpacing;\n};\n\nconst drawLine = (ctx, line, x, y, letterSpacing) => {\n const chars = Array.from(line);\n let cursor = x - measureLine(ctx, line, letterSpacing) / 2;\n\n chars.forEach((char, index) => {\n ctx.fillText(char, cursor, y);\n cursor += ctx.measureText(char).width + (index === chars.length - 1 ? 0 : letterSpacing);\n });\n};\n\nconst buildTextCanvas = ({ container, width, height, dpr, props }) => {\n const canvas = document.createElement('canvas');\n canvas.width = Math.max(1, Math.floor(width * dpr));\n canvas.height = Math.max(1, Math.floor(height * dpr));\n\n const ctx = canvas.getContext('2d');\n if (!ctx) return canvas;\n\n const probe = document.createElement('span');\n probe.textContent = props.text;\n Object.assign(probe.style, {\n position: 'absolute',\n visibility: 'hidden',\n pointerEvents: 'none',\n whiteSpace: 'pre',\n inset: '0 auto auto 0',\n fontFamily: props.fontFamily,\n fontSize: getFontValue(props.fontSize),\n fontWeight: String(props.fontWeight),\n letterSpacing: getFontValue(props.letterSpacing),\n lineHeight: typeof props.lineHeight === 'number' ? String(props.lineHeight) : props.lineHeight\n });\n container.appendChild(probe);\n const computed = window.getComputedStyle(probe);\n let fontSizePx = parseFloat(computed.fontSize) || 96;\n const fontFamily = computed.fontFamily || 'sans-serif';\n const fontWeight = computed.fontWeight || String(props.fontWeight);\n let letterSpacing = computed.letterSpacing === 'normal' ? 0 : parseFloat(computed.letterSpacing) || 0;\n let lineHeight = parseFloat(computed.lineHeight);\n if (!Number.isFinite(lineHeight)) {\n lineHeight = fontSizePx * (typeof props.lineHeight === 'number' ? props.lineHeight : 0.92);\n }\n probe.remove();\n\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n ctx.clearRect(0, 0, width, height);\n ctx.textAlign = 'left';\n ctx.textBaseline = 'middle';\n ctx.fillStyle = props.color;\n ctx.imageSmoothingEnabled = true;\n ctx.imageSmoothingQuality = 'high';\n\n const lines = String(props.text || '').split('\\n');\n const applyFont = () => {\n ctx.font = `${fontWeight} ${fontSizePx}px ${fontFamily}`;\n };\n applyFont();\n\n const maxWidth = width * 0.86;\n const maxHeight = height * 0.78;\n const widest = Math.max(...lines.map(line => measureLine(ctx, line, letterSpacing)), 1);\n const blockHeight = Math.max(lineHeight * lines.length, 1);\n const fit = Math.min(1, maxWidth / widest, maxHeight / blockHeight);\n\n if (fit < 1) {\n fontSizePx *= fit;\n letterSpacing *= fit;\n lineHeight *= fit;\n applyFont();\n }\n\n const startY = height / 2 - (lineHeight * (lines.length - 1)) / 2;\n lines.forEach((line, index) => drawLine(ctx, line, width / 2, startY + index * lineHeight, letterSpacing));\n\n return canvas;\n};\n\nconst syncUniforms = (program, props) => {\n const uniforms = program.uniforms;\n uniforms.uWarpStrength.value = props.warpStrength;\n uniforms.uWarpScale.value = props.warpScale;\n uniforms.uSpeed.value = props.speed;\n uniforms.uPointerInfluence.value = props.pointerInfluence;\n uniforms.uPointerStrength.value = props.pointerStrength;\n uniforms.uRefraction.value = props.refraction;\n uniforms.uRipple.value = props.ripple ? 1 : 0;\n};\n\nconst WarpText = ({\n text = 'Bend the moment',\n color = '#f8f5ff',\n warpStrength = 0.08,\n warpScale = 1.7,\n speed = 0.55,\n pointerInfluence = 0.42,\n pointerStrength = 0.38,\n refraction = 0.018,\n ripple = true,\n fontSize = 'clamp(3rem, 10vw, 9rem)',\n fontWeight = 800,\n fontFamily = 'inherit',\n letterSpacing = '-0.06em',\n lineHeight = 0.9,\n className = '',\n style\n}) => {\n const containerRef = useRef(null);\n const propsRef = useRef({\n text,\n color,\n fontSize,\n fontWeight,\n fontFamily,\n letterSpacing,\n lineHeight,\n warpStrength,\n warpScale,\n speed,\n pointerInfluence,\n pointerStrength,\n refraction,\n ripple\n });\n const contextRef = useRef(null);\n\n useEffect(() => {\n propsRef.current = {\n text,\n color,\n fontSize,\n fontWeight,\n fontFamily,\n letterSpacing,\n lineHeight,\n warpStrength,\n warpScale,\n speed,\n pointerInfluence,\n pointerStrength,\n refraction,\n ripple\n };\n\n if (contextRef.current) {\n syncUniforms(contextRef.current.program, propsRef.current);\n contextRef.current.rasterize();\n }\n }, [\n text,\n color,\n fontSize,\n fontWeight,\n fontFamily,\n letterSpacing,\n lineHeight,\n warpStrength,\n warpScale,\n speed,\n pointerInfluence,\n pointerStrength,\n refraction,\n ripple\n ]);\n\n useEffect(() => {\n const container = containerRef.current;\n if (!container || typeof window === 'undefined') return undefined;\n\n let renderer;\n let gl;\n let program;\n let geometry;\n let mesh;\n let texture;\n let resizeObserver;\n let intersectionObserver;\n let raf = 0;\n let disposed = false;\n let contextLost = false;\n let visible = true;\n let pageVisible = !document.hidden;\n let reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;\n let rasterVersion = 0;\n\n const pointer = { x: 0.5, y: 0.5, tx: 0.5, ty: 0.5, active: 0, activeTarget: 0 };\n const startTime = performance.now();\n\n try {\n renderer = new Renderer({\n webgl: 2,\n alpha: true,\n premultipliedAlpha: false,\n antialias: true,\n dpr: Math.min(window.devicePixelRatio || 1, 2)\n });\n gl = renderer.gl;\n } catch (error) {\n console.warn('WarpText: WebGL could not be initialized.', error);\n return undefined;\n }\n\n gl.clearColor(0, 0, 0, 0);\n const canvas = gl.canvas;\n canvas.style.position = 'absolute';\n canvas.style.inset = '0';\n canvas.style.width = '100%';\n canvas.style.height = '100%';\n canvas.style.display = 'block';\n canvas.setAttribute('aria-hidden', 'true');\n container.appendChild(canvas);\n\n texture = new Texture(gl, {\n generateMipmaps: false,\n minFilter: gl.LINEAR,\n magFilter: gl.LINEAR,\n wrapS: gl.CLAMP_TO_EDGE,\n wrapT: gl.CLAMP_TO_EDGE\n });\n\n geometry = new Triangle(gl);\n program = new Program(gl, {\n vertex,\n fragment,\n transparent: true,\n depthTest: false,\n depthWrite: false,\n uniforms: {\n uTextTexture: { value: texture },\n uResolution: { value: new Float32Array([1, 1]) },\n uPointer: { value: new Float32Array([0.5, 0.5]) },\n uPointerActive: { value: 0 },\n uTime: { value: 0 },\n uWarpStrength: { value: propsRef.current.warpStrength },\n uWarpScale: { value: propsRef.current.warpScale },\n uSpeed: { value: propsRef.current.speed },\n uPointerInfluence: { value: propsRef.current.pointerInfluence },\n uPointerStrength: { value: propsRef.current.pointerStrength },\n uRefraction: { value: propsRef.current.refraction },\n uRipple: { value: propsRef.current.ripple ? 1 : 0 },\n uMotion: { value: reduceMotion ? 0 : 1 }\n }\n });\n mesh = new Mesh(gl, { geometry, program });\n\n const renderOnce = () => {\n if (disposed || contextLost) return;\n renderer.render({ scene: mesh });\n };\n\n const rasterize = async () => {\n const version = ++rasterVersion;\n if (document.fonts?.ready) {\n try {\n await document.fonts.ready;\n } catch {}\n }\n if (disposed || contextLost || version !== rasterVersion) return;\n\n const rect = container.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n const textCanvas = buildTextCanvas({\n container,\n width: rect.width,\n height: rect.height,\n dpr,\n props: propsRef.current\n });\n texture.image = textCanvas;\n texture.needsUpdate = true;\n renderOnce();\n };\n\n const resize = () => {\n if (disposed || contextLost) return;\n const rect = container.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n\n renderer.dpr = Math.min(window.devicePixelRatio || 1, 2);\n renderer.setSize(rect.width, rect.height);\n program.uniforms.uResolution.value[0] = gl.drawingBufferWidth;\n program.uniforms.uResolution.value[1] = gl.drawingBufferHeight;\n rasterize();\n };\n\n const onPointerMove = event => {\n if (event.pointerType === 'touch') return;\n const rect = canvas.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n pointer.tx = (event.clientX - rect.left) / rect.width;\n pointer.ty = 1 - (event.clientY - rect.top) / rect.height;\n pointer.activeTarget = 1;\n };\n\n const onPointerLeave = () => {\n pointer.activeTarget = 0;\n };\n\n const onContextLost = event => {\n event.preventDefault();\n contextLost = true;\n if (raf) cancelAnimationFrame(raf);\n raf = 0;\n };\n\n const onVisibility = () => {\n pageVisible = !document.hidden;\n if (pageVisible && visible && !raf) raf = requestAnimationFrame(loop);\n if (!pageVisible && raf) {\n cancelAnimationFrame(raf);\n raf = 0;\n }\n };\n\n const mediaQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)');\n const onReducedMotion = event => {\n reduceMotion = event.matches;\n program.uniforms.uMotion.value = reduceMotion ? 0 : 1;\n renderOnce();\n };\n\n const loop = now => {\n if (disposed || contextLost) return;\n\n const elapsed = (now - startTime) * 0.001;\n const idleX = 0.5 + Math.sin(elapsed * 0.33) * 0.12;\n const idleY = 0.5 + Math.cos(elapsed * 0.27) * 0.1;\n const targetX = pointer.activeTarget > 0 ? pointer.tx : idleX;\n const targetY = pointer.activeTarget > 0 ? pointer.ty : idleY;\n const damping = pointer.activeTarget > 0 ? 0.12 : 0.035;\n\n pointer.x += (targetX - pointer.x) * damping;\n pointer.y += (targetY - pointer.y) * damping;\n pointer.active += ((pointer.activeTarget > 0 ? 1 : 0.18) - pointer.active) * 0.06;\n\n program.uniforms.uPointer.value[0] = pointer.x;\n program.uniforms.uPointer.value[1] = pointer.y;\n program.uniforms.uPointerActive.value = reduceMotion ? pointer.active * 0.35 : pointer.active;\n program.uniforms.uTime.value = reduceMotion ? 0 : elapsed;\n\n renderOnce();\n raf = requestAnimationFrame(loop);\n };\n\n resizeObserver = new ResizeObserver(resize);\n resizeObserver.observe(container);\n\n intersectionObserver = new IntersectionObserver(\n ([entry]) => {\n visible = entry.isIntersecting;\n if (visible && pageVisible && !raf) raf = requestAnimationFrame(loop);\n if (!visible && raf) {\n cancelAnimationFrame(raf);\n raf = 0;\n }\n },\n { threshold: 0 }\n );\n intersectionObserver.observe(container);\n\n canvas.addEventListener('pointermove', onPointerMove);\n canvas.addEventListener('pointerleave', onPointerLeave);\n canvas.addEventListener('webglcontextlost', onContextLost, false);\n document.addEventListener('visibilitychange', onVisibility);\n mediaQuery?.addEventListener('change', onReducedMotion);\n\n syncUniforms(program, propsRef.current);\n contextRef.current = { program, rasterize };\n resize();\n raf = requestAnimationFrame(loop);\n\n return () => {\n disposed = true;\n contextRef.current = null;\n if (raf) cancelAnimationFrame(raf);\n resizeObserver?.disconnect();\n intersectionObserver?.disconnect();\n canvas.removeEventListener('pointermove', onPointerMove);\n canvas.removeEventListener('pointerleave', onPointerLeave);\n canvas.removeEventListener('webglcontextlost', onContextLost);\n document.removeEventListener('visibilitychange', onVisibility);\n mediaQuery?.removeEventListener('change', onReducedMotion);\n\n if (!contextLost) {\n try {\n if (texture?.texture) gl.deleteTexture(texture.texture);\n geometry?.remove?.();\n program?.remove?.();\n gl.getExtension('WEBGL_lose_context')?.loseContext();\n } catch {}\n }\n\n if (canvas.parentNode === container) container.removeChild(canvas);\n };\n }, []);\n\n return (\n \n );\n};\n\nexport default WarpText;\n" + "content": "import { useEffect, useRef } from 'react';\nimport { Renderer, Program, Mesh, Triangle, Texture } from 'ogl';\n\nconst vertex = `#version 300 es\nin vec2 position;\nin vec2 uv;\nout vec2 vUv;\nvoid main() {\n vUv = uv;\n gl_Position = vec4(position, 0.0, 1.0);\n}\n`;\n\nconst fragment = `#version 300 es\nprecision highp float;\n\nuniform sampler2D uTextTexture;\nuniform vec2 uResolution;\nuniform vec2 uPointer;\nuniform float uPointerActive;\nuniform float uTime;\nuniform float uWarpStrength;\nuniform float uWarpScale;\nuniform float uSpeed;\nuniform float uPointerInfluence;\nuniform float uPointerStrength;\nuniform float uRefraction;\nuniform float uRipple;\nuniform float uMotion;\n\nin vec2 vUv;\nout vec4 fragColor;\n\nfloat hash(vec2 p) {\n p = fract(p * vec2(123.34, 456.21));\n p += dot(p, p + 45.32);\n return fract(p.x * p.y);\n}\n\nfloat noise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n\n float a = hash(i);\n float b = hash(i + vec2(1.0, 0.0));\n float c = hash(i + vec2(0.0, 1.0));\n float d = hash(i + vec2(1.0, 1.0));\n\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfloat fbm(vec2 p) {\n float value = 0.0;\n float amplitude = 0.5;\n for (int i = 0; i < 4; i++) {\n value += amplitude * noise(p);\n p *= 2.02;\n amplitude *= 0.5;\n }\n return value;\n}\n\nvec4 sampleText(vec2 uv) {\n if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {\n return vec4(0.0);\n }\n return texture(uTextTexture, uv);\n}\n\nvoid main() {\n vec2 uv = vUv;\n float aspect = uResolution.x / max(uResolution.y, 1.0);\n float time = uTime * uSpeed;\n float scale = max(uWarpScale, 0.001);\n\n vec2 drift = vec2(time * 0.055, -time * 0.045);\n float n1 = fbm(uv * scale * 3.1 + drift);\n float n2 = fbm((uv + 19.17) * scale * 3.4 - drift.yx);\n vec2 ambient = (vec2(n1, n2) - 0.5) * uWarpStrength * 0.045 * uMotion;\n\n vec2 pointerDelta = uv - uPointer;\n vec2 aspectDelta = vec2(pointerDelta.x * aspect, pointerDelta.y);\n float dist = length(aspectDelta);\n float radius = max(uPointerInfluence, 0.001);\n float t = clamp(dist / radius, 0.0, 1.0);\n float lens = smoothstep(radius, 0.0, dist) * uPointerActive;\n float bulge = t * (1.0 - t) * (1.0 - t) * 6.75 * uPointerActive;\n vec2 dir = dist > 0.0001 ? vec2(aspectDelta.x / aspect, aspectDelta.y) / dist : vec2(0.0);\n\n float rippleWave = sin(dist * 28.0 - time * 4.2) * 0.5 + 0.5;\n float rippleRing = (rippleWave - 0.5) * uRipple;\n vec2 pointerWarp = -dir * bulge * uPointerStrength * 0.045;\n pointerWarp += dir * rippleRing * bulge * uPointerStrength * 0.016;\n\n vec2 displaced = uv + ambient + pointerWarp;\n vec2 splitDir = ambient + pointerWarp;\n float splitLen = length(splitDir);\n splitDir = splitLen > 0.00001 ? splitDir / splitLen : vec2(0.7071, 0.7071);\n vec2 split = splitDir * uRefraction * 0.16 * (0.35 + lens * 1.65);\n\n vec4 base = sampleText(displaced);\n float r = sampleText(displaced + split).r;\n float g = base.g;\n float b = sampleText(displaced - split).b;\n float a = max(max(sampleText(displaced + split).a, base.a), sampleText(displaced - split).a);\n\n vec3 color = vec3(r, g, b) + lens * base.a * 0.055;\n fragColor = vec4(color, a);\n}\n`;\n\nconst getFontValue = value => (typeof value === 'number' ? `${value}px` : value);\n\nconst measureLine = (ctx, line, letterSpacing) => {\n const chars = Array.from(line);\n const textWidth = chars.reduce((width, char) => width + ctx.measureText(char).width, 0);\n return textWidth + Math.max(0, chars.length - 1) * letterSpacing;\n};\n\nconst drawLine = (ctx, line, x, y, letterSpacing) => {\n const chars = Array.from(line);\n let cursor = x - measureLine(ctx, line, letterSpacing) / 2;\n\n chars.forEach((char, index) => {\n ctx.fillText(char, cursor, y);\n cursor += ctx.measureText(char).width + (index === chars.length - 1 ? 0 : letterSpacing);\n });\n};\n\nconst buildTextCanvas = ({ container, width, height, dpr, props }) => {\n const canvas = document.createElement('canvas');\n canvas.width = Math.max(1, Math.floor(width * dpr));\n canvas.height = Math.max(1, Math.floor(height * dpr));\n\n const ctx = canvas.getContext('2d');\n if (!ctx) return canvas;\n\n const probe = document.createElement('span');\n probe.textContent = props.text;\n Object.assign(probe.style, {\n position: 'absolute',\n visibility: 'hidden',\n pointerEvents: 'none',\n whiteSpace: 'pre',\n inset: '0 auto auto 0',\n fontFamily: props.fontFamily,\n fontSize: getFontValue(props.fontSize),\n fontWeight: String(props.fontWeight),\n letterSpacing: getFontValue(props.letterSpacing),\n lineHeight: typeof props.lineHeight === 'number' ? String(props.lineHeight) : props.lineHeight\n });\n container.appendChild(probe);\n const computed = window.getComputedStyle(probe);\n let fontSizePx = parseFloat(computed.fontSize) || 96;\n const fontFamily = computed.fontFamily || 'sans-serif';\n const fontWeight = computed.fontWeight || String(props.fontWeight);\n let letterSpacing = computed.letterSpacing === 'normal' ? 0 : parseFloat(computed.letterSpacing) || 0;\n let lineHeight = parseFloat(computed.lineHeight);\n if (!Number.isFinite(lineHeight)) {\n lineHeight = fontSizePx * (typeof props.lineHeight === 'number' ? props.lineHeight : 0.92);\n }\n probe.remove();\n\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n ctx.clearRect(0, 0, width, height);\n ctx.textAlign = 'left';\n ctx.textBaseline = 'middle';\n ctx.fillStyle = props.color;\n ctx.imageSmoothingEnabled = true;\n ctx.imageSmoothingQuality = 'high';\n\n const lines = String(props.text || '').split('\\n');\n const applyFont = () => {\n ctx.font = `${fontWeight} ${fontSizePx}px ${fontFamily}`;\n };\n applyFont();\n\n const maxWidth = width * 0.86;\n const maxHeight = height * 0.78;\n const widest = Math.max(...lines.map(line => measureLine(ctx, line, letterSpacing)), 1);\n const blockHeight = Math.max(lineHeight * lines.length, 1);\n const fit = Math.min(1, maxWidth / widest, maxHeight / blockHeight);\n\n if (fit < 1) {\n fontSizePx *= fit;\n letterSpacing *= fit;\n lineHeight *= fit;\n applyFont();\n }\n\n const startY = height / 2 - (lineHeight * (lines.length - 1)) / 2;\n lines.forEach((line, index) => drawLine(ctx, line, width / 2, startY + index * lineHeight, letterSpacing));\n\n return canvas;\n};\n\nconst syncUniforms = (program, props) => {\n const uniforms = program.uniforms;\n uniforms.uWarpStrength.value = props.warpStrength;\n uniforms.uWarpScale.value = props.warpScale;\n uniforms.uSpeed.value = props.speed;\n uniforms.uPointerInfluence.value = props.pointerInfluence;\n uniforms.uPointerStrength.value = props.pointerStrength;\n uniforms.uRefraction.value = props.refraction;\n uniforms.uRipple.value = props.ripple ? 1 : 0;\n};\n\nconst WarpText = ({\n text = 'Bend the moment',\n color = '#f8f5ff',\n warpStrength = 0.08,\n warpScale = 1.7,\n speed = 0.55,\n pointerInfluence = 0.42,\n pointerStrength = 0.38,\n refraction = 0.018,\n ripple = true,\n fontSize = 'clamp(3rem, 10vw, 9rem)',\n fontWeight = 800,\n fontFamily = 'inherit',\n letterSpacing = '-0.06em',\n lineHeight = 0.9,\n className = '',\n style\n}) => {\n const containerRef = useRef(null);\n const propsRef = useRef({\n text,\n color,\n fontSize,\n fontWeight,\n fontFamily,\n letterSpacing,\n lineHeight,\n warpStrength,\n warpScale,\n speed,\n pointerInfluence,\n pointerStrength,\n refraction,\n ripple\n });\n const contextRef = useRef(null);\n\n useEffect(() => {\n propsRef.current = {\n text,\n color,\n fontSize,\n fontWeight,\n fontFamily,\n letterSpacing,\n lineHeight,\n warpStrength,\n warpScale,\n speed,\n pointerInfluence,\n pointerStrength,\n refraction,\n ripple\n };\n\n if (contextRef.current) {\n syncUniforms(contextRef.current.program, propsRef.current);\n contextRef.current.rasterize();\n }\n }, [\n text,\n color,\n fontSize,\n fontWeight,\n fontFamily,\n letterSpacing,\n lineHeight,\n warpStrength,\n warpScale,\n speed,\n pointerInfluence,\n pointerStrength,\n refraction,\n ripple\n ]);\n\n useEffect(() => {\n const container = containerRef.current;\n if (!container || typeof window === 'undefined') return undefined;\n\n let renderer;\n let gl;\n let program;\n let geometry;\n let mesh;\n let texture;\n let resizeObserver;\n let intersectionObserver;\n let raf = 0;\n let disposed = false;\n let contextLost = false;\n let visible = true;\n let pageVisible = !document.hidden;\n let reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;\n let rasterVersion = 0;\n\n const pointer = { x: 0.5, y: 0.5, tx: 0.5, ty: 0.5, active: 0, activeTarget: 0 };\n const startTime = performance.now();\n\n try {\n renderer = new Renderer({\n webgl: 2,\n alpha: true,\n premultipliedAlpha: false,\n antialias: true,\n dpr: Math.min(window.devicePixelRatio || 1, 2)\n });\n gl = renderer.gl;\n } catch (error) {\n console.warn('WarpText: WebGL could not be initialized.', error);\n return undefined;\n }\n\n gl.clearColor(0, 0, 0, 0);\n const canvas = gl.canvas;\n canvas.style.position = 'absolute';\n canvas.style.inset = '0';\n canvas.style.width = '100%';\n canvas.style.height = '100%';\n canvas.style.display = 'block';\n canvas.setAttribute('aria-hidden', 'true');\n container.appendChild(canvas);\n\n texture = new Texture(gl, {\n generateMipmaps: false,\n minFilter: gl.LINEAR,\n magFilter: gl.LINEAR,\n wrapS: gl.CLAMP_TO_EDGE,\n wrapT: gl.CLAMP_TO_EDGE\n });\n\n geometry = new Triangle(gl);\n program = new Program(gl, {\n vertex,\n fragment,\n transparent: true,\n depthTest: false,\n depthWrite: false,\n uniforms: {\n uTextTexture: { value: texture },\n uResolution: { value: new Float32Array([1, 1]) },\n uPointer: { value: new Float32Array([0.5, 0.5]) },\n uPointerActive: { value: 0 },\n uTime: { value: 0 },\n uWarpStrength: { value: propsRef.current.warpStrength },\n uWarpScale: { value: propsRef.current.warpScale },\n uSpeed: { value: propsRef.current.speed },\n uPointerInfluence: { value: propsRef.current.pointerInfluence },\n uPointerStrength: { value: propsRef.current.pointerStrength },\n uRefraction: { value: propsRef.current.refraction },\n uRipple: { value: propsRef.current.ripple ? 1 : 0 },\n uMotion: { value: reduceMotion ? 0 : 1 }\n }\n });\n mesh = new Mesh(gl, { geometry, program });\n\n const renderOnce = () => {\n if (disposed || contextLost) return;\n renderer.render({ scene: mesh });\n };\n\n const rasterize = async () => {\n const version = ++rasterVersion;\n if (document.fonts?.ready) {\n try {\n await document.fonts.ready;\n } catch (error) {\n void error;\n }\n }\n if (disposed || contextLost || version !== rasterVersion) return;\n\n const rect = container.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n const textCanvas = buildTextCanvas({\n container,\n width: rect.width,\n height: rect.height,\n dpr,\n props: propsRef.current\n });\n texture.image = textCanvas;\n texture.needsUpdate = true;\n renderOnce();\n };\n\n const resize = () => {\n if (disposed || contextLost) return;\n const rect = container.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n\n renderer.dpr = Math.min(window.devicePixelRatio || 1, 2);\n renderer.setSize(rect.width, rect.height);\n program.uniforms.uResolution.value[0] = gl.drawingBufferWidth;\n program.uniforms.uResolution.value[1] = gl.drawingBufferHeight;\n rasterize();\n };\n\n const onPointerMove = event => {\n if (event.pointerType === 'touch') return;\n const rect = canvas.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n pointer.tx = (event.clientX - rect.left) / rect.width;\n pointer.ty = 1 - (event.clientY - rect.top) / rect.height;\n pointer.activeTarget = 1;\n };\n\n const onPointerLeave = () => {\n pointer.activeTarget = 0;\n };\n\n const onContextLost = event => {\n event.preventDefault();\n contextLost = true;\n if (raf) cancelAnimationFrame(raf);\n raf = 0;\n };\n\n const onVisibility = () => {\n pageVisible = !document.hidden;\n if (pageVisible && visible && !raf) raf = requestAnimationFrame(loop);\n if (!pageVisible && raf) {\n cancelAnimationFrame(raf);\n raf = 0;\n }\n };\n\n const mediaQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)');\n const onReducedMotion = event => {\n reduceMotion = event.matches;\n program.uniforms.uMotion.value = reduceMotion ? 0 : 1;\n renderOnce();\n };\n\n const loop = now => {\n if (disposed || contextLost) return;\n\n const elapsed = (now - startTime) * 0.001;\n const idleX = 0.5 + Math.sin(elapsed * 0.33) * 0.12;\n const idleY = 0.5 + Math.cos(elapsed * 0.27) * 0.1;\n const targetX = pointer.activeTarget > 0 ? pointer.tx : idleX;\n const targetY = pointer.activeTarget > 0 ? pointer.ty : idleY;\n const damping = pointer.activeTarget > 0 ? 0.12 : 0.035;\n\n pointer.x += (targetX - pointer.x) * damping;\n pointer.y += (targetY - pointer.y) * damping;\n pointer.active += ((pointer.activeTarget > 0 ? 1 : 0.18) - pointer.active) * 0.06;\n\n program.uniforms.uPointer.value[0] = pointer.x;\n program.uniforms.uPointer.value[1] = pointer.y;\n program.uniforms.uPointerActive.value = reduceMotion ? pointer.active * 0.35 : pointer.active;\n program.uniforms.uTime.value = reduceMotion ? 0 : elapsed;\n\n renderOnce();\n raf = requestAnimationFrame(loop);\n };\n\n resizeObserver = new ResizeObserver(resize);\n resizeObserver.observe(container);\n\n intersectionObserver = new IntersectionObserver(\n ([entry]) => {\n visible = entry.isIntersecting;\n if (visible && pageVisible && !raf) raf = requestAnimationFrame(loop);\n if (!visible && raf) {\n cancelAnimationFrame(raf);\n raf = 0;\n }\n },\n { threshold: 0 }\n );\n intersectionObserver.observe(container);\n\n canvas.addEventListener('pointermove', onPointerMove);\n canvas.addEventListener('pointerleave', onPointerLeave);\n canvas.addEventListener('webglcontextlost', onContextLost, false);\n document.addEventListener('visibilitychange', onVisibility);\n mediaQuery?.addEventListener('change', onReducedMotion);\n\n syncUniforms(program, propsRef.current);\n contextRef.current = { program, rasterize };\n resize();\n raf = requestAnimationFrame(loop);\n\n return () => {\n disposed = true;\n contextRef.current = null;\n if (raf) cancelAnimationFrame(raf);\n resizeObserver?.disconnect();\n intersectionObserver?.disconnect();\n canvas.removeEventListener('pointermove', onPointerMove);\n canvas.removeEventListener('pointerleave', onPointerLeave);\n canvas.removeEventListener('webglcontextlost', onContextLost);\n document.removeEventListener('visibilitychange', onVisibility);\n mediaQuery?.removeEventListener('change', onReducedMotion);\n\n if (!contextLost) {\n try {\n if (texture?.texture) gl.deleteTexture(texture.texture);\n geometry?.remove?.();\n program?.remove?.();\n gl.getExtension('WEBGL_lose_context')?.loseContext();\n } catch (error) {\n void error;\n }\n }\n\n if (canvas.parentNode === container) container.removeChild(canvas);\n };\n }, []);\n\n return (\n \n );\n};\n\nexport default WarpText;\n" } ], "registryDependencies": [], diff --git a/public/r/WarpText-TS-CSS.json b/public/r/WarpText-TS-CSS.json index 5162d59e1..3dec270d7 100644 --- a/public/r/WarpText-TS-CSS.json +++ b/public/r/WarpText-TS-CSS.json @@ -14,7 +14,7 @@ { "type": "registry:component", "path": "WarpText.tsx", - "content": "import { useEffect, useRef, type CSSProperties } from 'react';\nimport { Renderer, Program, Mesh, Triangle, Texture, type OGLRenderingContext } from 'ogl';\nimport './WarpText.css';\n\nexport interface Props {\n text?: string;\n color?: string;\n warpStrength?: number;\n warpScale?: number;\n speed?: number;\n pointerInfluence?: number;\n pointerStrength?: number;\n refraction?: number;\n ripple?: boolean;\n fontSize?: string | number;\n fontWeight?: string | number;\n fontFamily?: string;\n letterSpacing?: string | number;\n lineHeight?: string | number;\n className?: string;\n style?: CSSProperties;\n}\n\ninterface RuntimeProps {\n text: string;\n color: string;\n fontSize: string | number;\n fontWeight: string | number;\n fontFamily: string;\n letterSpacing: string | number;\n lineHeight: string | number;\n warpStrength: number;\n warpScale: number;\n speed: number;\n pointerInfluence: number;\n pointerStrength: number;\n refraction: number;\n ripple: boolean;\n}\n\ninterface RuntimeContext {\n program: Program;\n rasterize: () => void;\n}\n\ninterface BuildTextCanvasArgs {\n container: HTMLElement;\n width: number;\n height: number;\n dpr: number;\n props: RuntimeProps;\n}\n\nconst vertex = `#version 300 es\nin vec2 position;\nin vec2 uv;\nout vec2 vUv;\nvoid main() {\n vUv = uv;\n gl_Position = vec4(position, 0.0, 1.0);\n}\n`;\n\nconst fragment = `#version 300 es\nprecision highp float;\n\nuniform sampler2D uTextTexture;\nuniform vec2 uResolution;\nuniform vec2 uPointer;\nuniform float uPointerActive;\nuniform float uTime;\nuniform float uWarpStrength;\nuniform float uWarpScale;\nuniform float uSpeed;\nuniform float uPointerInfluence;\nuniform float uPointerStrength;\nuniform float uRefraction;\nuniform float uRipple;\nuniform float uMotion;\n\nin vec2 vUv;\nout vec4 fragColor;\n\nfloat hash(vec2 p) {\n p = fract(p * vec2(123.34, 456.21));\n p += dot(p, p + 45.32);\n return fract(p.x * p.y);\n}\n\nfloat noise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n\n float a = hash(i);\n float b = hash(i + vec2(1.0, 0.0));\n float c = hash(i + vec2(0.0, 1.0));\n float d = hash(i + vec2(1.0, 1.0));\n\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfloat fbm(vec2 p) {\n float value = 0.0;\n float amplitude = 0.5;\n for (int i = 0; i < 4; i++) {\n value += amplitude * noise(p);\n p *= 2.02;\n amplitude *= 0.5;\n }\n return value;\n}\n\nvec4 sampleText(vec2 uv) {\n if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {\n return vec4(0.0);\n }\n return texture(uTextTexture, uv);\n}\n\nvoid main() {\n vec2 uv = vUv;\n float aspect = uResolution.x / max(uResolution.y, 1.0);\n float time = uTime * uSpeed;\n float scale = max(uWarpScale, 0.001);\n\n vec2 drift = vec2(time * 0.055, -time * 0.045);\n float n1 = fbm(uv * scale * 3.1 + drift);\n float n2 = fbm((uv + 19.17) * scale * 3.4 - drift.yx);\n vec2 ambient = (vec2(n1, n2) - 0.5) * uWarpStrength * 0.045 * uMotion;\n\n vec2 pointerDelta = uv - uPointer;\n vec2 aspectDelta = vec2(pointerDelta.x * aspect, pointerDelta.y);\n float dist = length(aspectDelta);\n float radius = max(uPointerInfluence, 0.001);\n float t = clamp(dist / radius, 0.0, 1.0);\n float lens = smoothstep(radius, 0.0, dist) * uPointerActive;\n float bulge = t * (1.0 - t) * (1.0 - t) * 6.75 * uPointerActive;\n vec2 dir = dist > 0.0001 ? vec2(aspectDelta.x / aspect, aspectDelta.y) / dist : vec2(0.0);\n\n float rippleWave = sin(dist * 28.0 - time * 4.2) * 0.5 + 0.5;\n float rippleRing = (rippleWave - 0.5) * uRipple;\n vec2 pointerWarp = -dir * bulge * uPointerStrength * 0.045;\n pointerWarp += dir * rippleRing * bulge * uPointerStrength * 0.016;\n\n vec2 displaced = uv + ambient + pointerWarp;\n vec2 splitDir = ambient + pointerWarp;\n float splitLen = length(splitDir);\n splitDir = splitLen > 0.00001 ? splitDir / splitLen : vec2(0.7071, 0.7071);\n vec2 split = splitDir * uRefraction * 0.16 * (0.35 + lens * 1.65);\n\n vec4 base = sampleText(displaced);\n float r = sampleText(displaced + split).r;\n float g = base.g;\n float b = sampleText(displaced - split).b;\n float a = max(max(sampleText(displaced + split).a, base.a), sampleText(displaced - split).a);\n\n vec3 color = vec3(r, g, b) + lens * base.a * 0.055;\n fragColor = vec4(color, a);\n}\n`;\n\nconst getFontValue = (value: string | number): string => (typeof value === 'number' ? `${value}px` : value);\n\nconst measureLine = (ctx: CanvasRenderingContext2D, line: string, letterSpacing: number): number => {\n const chars = Array.from(line);\n const textWidth = chars.reduce((width, char) => width + ctx.measureText(char).width, 0);\n return textWidth + Math.max(0, chars.length - 1) * letterSpacing;\n};\n\nconst drawLine = (ctx: CanvasRenderingContext2D, line: string, x: number, y: number, letterSpacing: number): void => {\n const chars = Array.from(line);\n let cursor = x - measureLine(ctx, line, letterSpacing) / 2;\n\n chars.forEach((char, index) => {\n ctx.fillText(char, cursor, y);\n cursor += ctx.measureText(char).width + (index === chars.length - 1 ? 0 : letterSpacing);\n });\n};\n\nconst buildTextCanvas = ({ container, width, height, dpr, props }: BuildTextCanvasArgs): HTMLCanvasElement => {\n const canvas = document.createElement('canvas');\n canvas.width = Math.max(1, Math.floor(width * dpr));\n canvas.height = Math.max(1, Math.floor(height * dpr));\n\n const ctx = canvas.getContext('2d');\n if (!ctx) return canvas;\n\n const probe = document.createElement('span');\n probe.textContent = props.text;\n Object.assign(probe.style, {\n position: 'absolute',\n visibility: 'hidden',\n pointerEvents: 'none',\n whiteSpace: 'pre',\n inset: '0 auto auto 0',\n fontFamily: props.fontFamily,\n fontSize: getFontValue(props.fontSize),\n fontWeight: String(props.fontWeight),\n letterSpacing: getFontValue(props.letterSpacing),\n lineHeight: typeof props.lineHeight === 'number' ? String(props.lineHeight) : props.lineHeight\n });\n container.appendChild(probe);\n const computed = window.getComputedStyle(probe);\n let fontSizePx = parseFloat(computed.fontSize) || 96;\n const fontFamily = computed.fontFamily || 'sans-serif';\n const fontWeight = computed.fontWeight || String(props.fontWeight);\n let letterSpacing = computed.letterSpacing === 'normal' ? 0 : parseFloat(computed.letterSpacing) || 0;\n let lineHeight = parseFloat(computed.lineHeight);\n if (!Number.isFinite(lineHeight)) {\n lineHeight = fontSizePx * (typeof props.lineHeight === 'number' ? props.lineHeight : 0.92);\n }\n probe.remove();\n\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n ctx.clearRect(0, 0, width, height);\n ctx.textAlign = 'left';\n ctx.textBaseline = 'middle';\n ctx.fillStyle = props.color;\n ctx.imageSmoothingEnabled = true;\n ctx.imageSmoothingQuality = 'high';\n\n const lines = String(props.text || '').split('\\n');\n const applyFont = () => {\n ctx.font = `${fontWeight} ${fontSizePx}px ${fontFamily}`;\n };\n applyFont();\n\n const maxWidth = width * 0.86;\n const maxHeight = height * 0.78;\n const widest = Math.max(...lines.map(line => measureLine(ctx, line, letterSpacing)), 1);\n const blockHeight = Math.max(lineHeight * lines.length, 1);\n const fit = Math.min(1, maxWidth / widest, maxHeight / blockHeight);\n\n if (fit < 1) {\n fontSizePx *= fit;\n letterSpacing *= fit;\n lineHeight *= fit;\n applyFont();\n }\n\n const startY = height / 2 - (lineHeight * (lines.length - 1)) / 2;\n lines.forEach((line, index) => drawLine(ctx, line, width / 2, startY + index * lineHeight, letterSpacing));\n\n return canvas;\n};\n\nconst syncUniforms = (program: Program, props: RuntimeProps): void => {\n const uniforms = program.uniforms;\n uniforms.uWarpStrength.value = props.warpStrength;\n uniforms.uWarpScale.value = props.warpScale;\n uniforms.uSpeed.value = props.speed;\n uniforms.uPointerInfluence.value = props.pointerInfluence;\n uniforms.uPointerStrength.value = props.pointerStrength;\n uniforms.uRefraction.value = props.refraction;\n uniforms.uRipple.value = props.ripple ? 1 : 0;\n};\n\nconst WarpText = ({\n text = 'Bend the moment',\n color = '#f8f5ff',\n warpStrength = 0.08,\n warpScale = 1.7,\n speed = 0.55,\n pointerInfluence = 0.42,\n pointerStrength = 0.38,\n refraction = 0.018,\n ripple = true,\n fontSize = 'clamp(3rem, 10vw, 9rem)',\n fontWeight = 800,\n fontFamily = 'inherit',\n letterSpacing = '-0.06em',\n lineHeight = 0.9,\n className = '',\n style\n}: Props) => {\n const containerRef = useRef(null);\n const propsRef = useRef({\n text,\n color,\n fontSize,\n fontWeight,\n fontFamily,\n letterSpacing,\n lineHeight,\n warpStrength,\n warpScale,\n speed,\n pointerInfluence,\n pointerStrength,\n refraction,\n ripple\n });\n const contextRef = useRef(null);\n\n useEffect(() => {\n propsRef.current = {\n text,\n color,\n fontSize,\n fontWeight,\n fontFamily,\n letterSpacing,\n lineHeight,\n warpStrength,\n warpScale,\n speed,\n pointerInfluence,\n pointerStrength,\n refraction,\n ripple\n };\n\n if (contextRef.current) {\n syncUniforms(contextRef.current.program, propsRef.current);\n contextRef.current.rasterize();\n }\n }, [\n text,\n color,\n fontSize,\n fontWeight,\n fontFamily,\n letterSpacing,\n lineHeight,\n warpStrength,\n warpScale,\n speed,\n pointerInfluence,\n pointerStrength,\n refraction,\n ripple\n ]);\n\n useEffect(() => {\n const container = containerRef.current;\n if (!container || typeof window === 'undefined') return undefined;\n\n let renderer: Renderer;\n let gl: OGLRenderingContext;\n let program: Program;\n let geometry: Triangle;\n let mesh: Mesh;\n let texture: Texture;\n let resizeObserver: ResizeObserver | null = null;\n let intersectionObserver: IntersectionObserver | null = null;\n let raf = 0;\n let disposed = false;\n let contextLost = false;\n let visible = true;\n let pageVisible = !document.hidden;\n let reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;\n let rasterVersion = 0;\n\n const pointer = { x: 0.5, y: 0.5, tx: 0.5, ty: 0.5, active: 0, activeTarget: 0 };\n const startTime = performance.now();\n\n try {\n renderer = new Renderer({\n webgl: 2,\n alpha: true,\n premultipliedAlpha: false,\n antialias: true,\n dpr: Math.min(window.devicePixelRatio || 1, 2)\n });\n gl = renderer.gl;\n } catch (error) {\n console.warn('WarpText: WebGL could not be initialized.', error);\n return undefined;\n }\n\n gl.clearColor(0, 0, 0, 0);\n const canvas = gl.canvas;\n canvas.style.position = 'absolute';\n canvas.style.inset = '0';\n canvas.style.width = '100%';\n canvas.style.height = '100%';\n canvas.style.display = 'block';\n canvas.setAttribute('aria-hidden', 'true');\n container.appendChild(canvas);\n\n texture = new Texture(gl, {\n generateMipmaps: false,\n minFilter: gl.LINEAR,\n magFilter: gl.LINEAR,\n wrapS: gl.CLAMP_TO_EDGE,\n wrapT: gl.CLAMP_TO_EDGE\n });\n\n geometry = new Triangle(gl);\n program = new Program(gl, {\n vertex,\n fragment,\n transparent: true,\n depthTest: false,\n depthWrite: false,\n uniforms: {\n uTextTexture: { value: texture },\n uResolution: { value: new Float32Array([1, 1]) },\n uPointer: { value: new Float32Array([0.5, 0.5]) },\n uPointerActive: { value: 0 },\n uTime: { value: 0 },\n uWarpStrength: { value: propsRef.current.warpStrength },\n uWarpScale: { value: propsRef.current.warpScale },\n uSpeed: { value: propsRef.current.speed },\n uPointerInfluence: { value: propsRef.current.pointerInfluence },\n uPointerStrength: { value: propsRef.current.pointerStrength },\n uRefraction: { value: propsRef.current.refraction },\n uRipple: { value: propsRef.current.ripple ? 1 : 0 },\n uMotion: { value: reduceMotion ? 0 : 1 }\n }\n });\n mesh = new Mesh(gl, { geometry, program });\n\n const renderOnce = () => {\n if (disposed || contextLost) return;\n renderer.render({ scene: mesh });\n };\n\n const rasterize = async () => {\n const version = ++rasterVersion;\n if (document.fonts?.ready) {\n try {\n await document.fonts.ready;\n } catch {}\n }\n if (disposed || contextLost || version !== rasterVersion) return;\n\n const rect = container.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n const textCanvas = buildTextCanvas({\n container,\n width: rect.width,\n height: rect.height,\n dpr,\n props: propsRef.current\n });\n texture.image = textCanvas;\n texture.needsUpdate = true;\n renderOnce();\n };\n\n const resize = () => {\n if (disposed || contextLost) return;\n const rect = container.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n\n renderer.dpr = Math.min(window.devicePixelRatio || 1, 2);\n renderer.setSize(rect.width, rect.height);\n program.uniforms.uResolution.value[0] = gl.drawingBufferWidth;\n program.uniforms.uResolution.value[1] = gl.drawingBufferHeight;\n rasterize();\n };\n\n const onPointerMove = (event: PointerEvent): void => {\n if (event.pointerType === 'touch') return;\n const rect = canvas.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n pointer.tx = (event.clientX - rect.left) / rect.width;\n pointer.ty = 1 - (event.clientY - rect.top) / rect.height;\n pointer.activeTarget = 1;\n };\n\n const onPointerLeave = (): void => {\n pointer.activeTarget = 0;\n };\n\n const onContextLost = (event: Event): void => {\n event.preventDefault();\n contextLost = true;\n if (raf) cancelAnimationFrame(raf);\n raf = 0;\n };\n\n const onVisibility = (): void => {\n pageVisible = !document.hidden;\n if (pageVisible && visible && !raf) raf = requestAnimationFrame(loop);\n if (!pageVisible && raf) {\n cancelAnimationFrame(raf);\n raf = 0;\n }\n };\n\n const mediaQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)');\n const onReducedMotion = (event: MediaQueryListEvent): void => {\n reduceMotion = event.matches;\n program.uniforms.uMotion.value = reduceMotion ? 0 : 1;\n renderOnce();\n };\n\n const loop = (now: number): void => {\n if (disposed || contextLost) return;\n\n const elapsed = (now - startTime) * 0.001;\n const idleX = 0.5 + Math.sin(elapsed * 0.33) * 0.12;\n const idleY = 0.5 + Math.cos(elapsed * 0.27) * 0.1;\n const targetX = pointer.activeTarget > 0 ? pointer.tx : idleX;\n const targetY = pointer.activeTarget > 0 ? pointer.ty : idleY;\n const damping = pointer.activeTarget > 0 ? 0.12 : 0.035;\n\n pointer.x += (targetX - pointer.x) * damping;\n pointer.y += (targetY - pointer.y) * damping;\n pointer.active += ((pointer.activeTarget > 0 ? 1 : 0.18) - pointer.active) * 0.06;\n\n program.uniforms.uPointer.value[0] = pointer.x;\n program.uniforms.uPointer.value[1] = pointer.y;\n program.uniforms.uPointerActive.value = reduceMotion ? pointer.active * 0.35 : pointer.active;\n program.uniforms.uTime.value = reduceMotion ? 0 : elapsed;\n\n renderOnce();\n raf = requestAnimationFrame(loop);\n };\n\n resizeObserver = new ResizeObserver(resize);\n resizeObserver.observe(container);\n\n intersectionObserver = new IntersectionObserver(\n ([entry]: IntersectionObserverEntry[]) => {\n visible = entry.isIntersecting;\n if (visible && pageVisible && !raf) raf = requestAnimationFrame(loop);\n if (!visible && raf) {\n cancelAnimationFrame(raf);\n raf = 0;\n }\n },\n { threshold: 0 }\n );\n intersectionObserver.observe(container);\n\n canvas.addEventListener('pointermove', onPointerMove);\n canvas.addEventListener('pointerleave', onPointerLeave);\n canvas.addEventListener('webglcontextlost', onContextLost, false);\n document.addEventListener('visibilitychange', onVisibility);\n mediaQuery?.addEventListener('change', onReducedMotion);\n\n syncUniforms(program, propsRef.current);\n contextRef.current = { program, rasterize };\n resize();\n raf = requestAnimationFrame(loop);\n\n return () => {\n disposed = true;\n contextRef.current = null;\n if (raf) cancelAnimationFrame(raf);\n resizeObserver?.disconnect();\n intersectionObserver?.disconnect();\n canvas.removeEventListener('pointermove', onPointerMove);\n canvas.removeEventListener('pointerleave', onPointerLeave);\n canvas.removeEventListener('webglcontextlost', onContextLost);\n document.removeEventListener('visibilitychange', onVisibility);\n mediaQuery?.removeEventListener('change', onReducedMotion);\n\n if (!contextLost) {\n try {\n if (texture?.texture) gl.deleteTexture(texture.texture);\n geometry?.remove?.();\n program?.remove?.();\n gl.getExtension('WEBGL_lose_context')?.loseContext();\n } catch {}\n }\n\n if (canvas.parentNode === container) container.removeChild(canvas);\n };\n }, []);\n\n return (\n
\n );\n};\n\nexport default WarpText;\n" + "content": "import { useEffect, useRef, type CSSProperties } from 'react';\nimport { Renderer, Program, Mesh, Triangle, Texture, type OGLRenderingContext } from 'ogl';\nimport './WarpText.css';\n\nexport interface Props {\n text?: string;\n color?: string;\n warpStrength?: number;\n warpScale?: number;\n speed?: number;\n pointerInfluence?: number;\n pointerStrength?: number;\n refraction?: number;\n ripple?: boolean;\n fontSize?: string | number;\n fontWeight?: string | number;\n fontFamily?: string;\n letterSpacing?: string | number;\n lineHeight?: string | number;\n className?: string;\n style?: CSSProperties;\n}\n\ninterface RuntimeProps {\n text: string;\n color: string;\n fontSize: string | number;\n fontWeight: string | number;\n fontFamily: string;\n letterSpacing: string | number;\n lineHeight: string | number;\n warpStrength: number;\n warpScale: number;\n speed: number;\n pointerInfluence: number;\n pointerStrength: number;\n refraction: number;\n ripple: boolean;\n}\n\ninterface RuntimeContext {\n program: Program;\n rasterize: () => void;\n}\n\ninterface BuildTextCanvasArgs {\n container: HTMLElement;\n width: number;\n height: number;\n dpr: number;\n props: RuntimeProps;\n}\n\nconst vertex = `#version 300 es\nin vec2 position;\nin vec2 uv;\nout vec2 vUv;\nvoid main() {\n vUv = uv;\n gl_Position = vec4(position, 0.0, 1.0);\n}\n`;\n\nconst fragment = `#version 300 es\nprecision highp float;\n\nuniform sampler2D uTextTexture;\nuniform vec2 uResolution;\nuniform vec2 uPointer;\nuniform float uPointerActive;\nuniform float uTime;\nuniform float uWarpStrength;\nuniform float uWarpScale;\nuniform float uSpeed;\nuniform float uPointerInfluence;\nuniform float uPointerStrength;\nuniform float uRefraction;\nuniform float uRipple;\nuniform float uMotion;\n\nin vec2 vUv;\nout vec4 fragColor;\n\nfloat hash(vec2 p) {\n p = fract(p * vec2(123.34, 456.21));\n p += dot(p, p + 45.32);\n return fract(p.x * p.y);\n}\n\nfloat noise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n\n float a = hash(i);\n float b = hash(i + vec2(1.0, 0.0));\n float c = hash(i + vec2(0.0, 1.0));\n float d = hash(i + vec2(1.0, 1.0));\n\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfloat fbm(vec2 p) {\n float value = 0.0;\n float amplitude = 0.5;\n for (int i = 0; i < 4; i++) {\n value += amplitude * noise(p);\n p *= 2.02;\n amplitude *= 0.5;\n }\n return value;\n}\n\nvec4 sampleText(vec2 uv) {\n if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {\n return vec4(0.0);\n }\n return texture(uTextTexture, uv);\n}\n\nvoid main() {\n vec2 uv = vUv;\n float aspect = uResolution.x / max(uResolution.y, 1.0);\n float time = uTime * uSpeed;\n float scale = max(uWarpScale, 0.001);\n\n vec2 drift = vec2(time * 0.055, -time * 0.045);\n float n1 = fbm(uv * scale * 3.1 + drift);\n float n2 = fbm((uv + 19.17) * scale * 3.4 - drift.yx);\n vec2 ambient = (vec2(n1, n2) - 0.5) * uWarpStrength * 0.045 * uMotion;\n\n vec2 pointerDelta = uv - uPointer;\n vec2 aspectDelta = vec2(pointerDelta.x * aspect, pointerDelta.y);\n float dist = length(aspectDelta);\n float radius = max(uPointerInfluence, 0.001);\n float t = clamp(dist / radius, 0.0, 1.0);\n float lens = smoothstep(radius, 0.0, dist) * uPointerActive;\n float bulge = t * (1.0 - t) * (1.0 - t) * 6.75 * uPointerActive;\n vec2 dir = dist > 0.0001 ? vec2(aspectDelta.x / aspect, aspectDelta.y) / dist : vec2(0.0);\n\n float rippleWave = sin(dist * 28.0 - time * 4.2) * 0.5 + 0.5;\n float rippleRing = (rippleWave - 0.5) * uRipple;\n vec2 pointerWarp = -dir * bulge * uPointerStrength * 0.045;\n pointerWarp += dir * rippleRing * bulge * uPointerStrength * 0.016;\n\n vec2 displaced = uv + ambient + pointerWarp;\n vec2 splitDir = ambient + pointerWarp;\n float splitLen = length(splitDir);\n splitDir = splitLen > 0.00001 ? splitDir / splitLen : vec2(0.7071, 0.7071);\n vec2 split = splitDir * uRefraction * 0.16 * (0.35 + lens * 1.65);\n\n vec4 base = sampleText(displaced);\n float r = sampleText(displaced + split).r;\n float g = base.g;\n float b = sampleText(displaced - split).b;\n float a = max(max(sampleText(displaced + split).a, base.a), sampleText(displaced - split).a);\n\n vec3 color = vec3(r, g, b) + lens * base.a * 0.055;\n fragColor = vec4(color, a);\n}\n`;\n\nconst getFontValue = (value: string | number): string => (typeof value === 'number' ? `${value}px` : value);\n\nconst measureLine = (ctx: CanvasRenderingContext2D, line: string, letterSpacing: number): number => {\n const chars = Array.from(line);\n const textWidth = chars.reduce((width, char) => width + ctx.measureText(char).width, 0);\n return textWidth + Math.max(0, chars.length - 1) * letterSpacing;\n};\n\nconst drawLine = (ctx: CanvasRenderingContext2D, line: string, x: number, y: number, letterSpacing: number): void => {\n const chars = Array.from(line);\n let cursor = x - measureLine(ctx, line, letterSpacing) / 2;\n\n chars.forEach((char, index) => {\n ctx.fillText(char, cursor, y);\n cursor += ctx.measureText(char).width + (index === chars.length - 1 ? 0 : letterSpacing);\n });\n};\n\nconst buildTextCanvas = ({ container, width, height, dpr, props }: BuildTextCanvasArgs): HTMLCanvasElement => {\n const canvas = document.createElement('canvas');\n canvas.width = Math.max(1, Math.floor(width * dpr));\n canvas.height = Math.max(1, Math.floor(height * dpr));\n\n const ctx = canvas.getContext('2d');\n if (!ctx) return canvas;\n\n const probe = document.createElement('span');\n probe.textContent = props.text;\n Object.assign(probe.style, {\n position: 'absolute',\n visibility: 'hidden',\n pointerEvents: 'none',\n whiteSpace: 'pre',\n inset: '0 auto auto 0',\n fontFamily: props.fontFamily,\n fontSize: getFontValue(props.fontSize),\n fontWeight: String(props.fontWeight),\n letterSpacing: getFontValue(props.letterSpacing),\n lineHeight: typeof props.lineHeight === 'number' ? String(props.lineHeight) : props.lineHeight\n });\n container.appendChild(probe);\n const computed = window.getComputedStyle(probe);\n let fontSizePx = parseFloat(computed.fontSize) || 96;\n const fontFamily = computed.fontFamily || 'sans-serif';\n const fontWeight = computed.fontWeight || String(props.fontWeight);\n let letterSpacing = computed.letterSpacing === 'normal' ? 0 : parseFloat(computed.letterSpacing) || 0;\n let lineHeight = parseFloat(computed.lineHeight);\n if (!Number.isFinite(lineHeight)) {\n lineHeight = fontSizePx * (typeof props.lineHeight === 'number' ? props.lineHeight : 0.92);\n }\n probe.remove();\n\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n ctx.clearRect(0, 0, width, height);\n ctx.textAlign = 'left';\n ctx.textBaseline = 'middle';\n ctx.fillStyle = props.color;\n ctx.imageSmoothingEnabled = true;\n ctx.imageSmoothingQuality = 'high';\n\n const lines = String(props.text || '').split('\\n');\n const applyFont = () => {\n ctx.font = `${fontWeight} ${fontSizePx}px ${fontFamily}`;\n };\n applyFont();\n\n const maxWidth = width * 0.86;\n const maxHeight = height * 0.78;\n const widest = Math.max(...lines.map(line => measureLine(ctx, line, letterSpacing)), 1);\n const blockHeight = Math.max(lineHeight * lines.length, 1);\n const fit = Math.min(1, maxWidth / widest, maxHeight / blockHeight);\n\n if (fit < 1) {\n fontSizePx *= fit;\n letterSpacing *= fit;\n lineHeight *= fit;\n applyFont();\n }\n\n const startY = height / 2 - (lineHeight * (lines.length - 1)) / 2;\n lines.forEach((line, index) => drawLine(ctx, line, width / 2, startY + index * lineHeight, letterSpacing));\n\n return canvas;\n};\n\nconst syncUniforms = (program: Program, props: RuntimeProps): void => {\n const uniforms = program.uniforms;\n uniforms.uWarpStrength.value = props.warpStrength;\n uniforms.uWarpScale.value = props.warpScale;\n uniforms.uSpeed.value = props.speed;\n uniforms.uPointerInfluence.value = props.pointerInfluence;\n uniforms.uPointerStrength.value = props.pointerStrength;\n uniforms.uRefraction.value = props.refraction;\n uniforms.uRipple.value = props.ripple ? 1 : 0;\n};\n\nconst WarpText = ({\n text = 'Bend the moment',\n color = '#f8f5ff',\n warpStrength = 0.08,\n warpScale = 1.7,\n speed = 0.55,\n pointerInfluence = 0.42,\n pointerStrength = 0.38,\n refraction = 0.018,\n ripple = true,\n fontSize = 'clamp(3rem, 10vw, 9rem)',\n fontWeight = 800,\n fontFamily = 'inherit',\n letterSpacing = '-0.06em',\n lineHeight = 0.9,\n className = '',\n style\n}: Props) => {\n const containerRef = useRef(null);\n const propsRef = useRef({\n text,\n color,\n fontSize,\n fontWeight,\n fontFamily,\n letterSpacing,\n lineHeight,\n warpStrength,\n warpScale,\n speed,\n pointerInfluence,\n pointerStrength,\n refraction,\n ripple\n });\n const contextRef = useRef(null);\n\n useEffect(() => {\n propsRef.current = {\n text,\n color,\n fontSize,\n fontWeight,\n fontFamily,\n letterSpacing,\n lineHeight,\n warpStrength,\n warpScale,\n speed,\n pointerInfluence,\n pointerStrength,\n refraction,\n ripple\n };\n\n if (contextRef.current) {\n syncUniforms(contextRef.current.program, propsRef.current);\n contextRef.current.rasterize();\n }\n }, [\n text,\n color,\n fontSize,\n fontWeight,\n fontFamily,\n letterSpacing,\n lineHeight,\n warpStrength,\n warpScale,\n speed,\n pointerInfluence,\n pointerStrength,\n refraction,\n ripple\n ]);\n\n useEffect(() => {\n const container = containerRef.current;\n if (!container || typeof window === 'undefined') return undefined;\n\n let renderer: Renderer;\n let gl: OGLRenderingContext;\n let program: Program;\n let geometry: Triangle;\n let mesh: Mesh;\n let texture: Texture;\n let resizeObserver: ResizeObserver | null = null;\n let intersectionObserver: IntersectionObserver | null = null;\n let raf = 0;\n let disposed = false;\n let contextLost = false;\n let visible = true;\n let pageVisible = !document.hidden;\n let reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;\n let rasterVersion = 0;\n\n const pointer = { x: 0.5, y: 0.5, tx: 0.5, ty: 0.5, active: 0, activeTarget: 0 };\n const startTime = performance.now();\n\n try {\n renderer = new Renderer({\n webgl: 2,\n alpha: true,\n premultipliedAlpha: false,\n antialias: true,\n dpr: Math.min(window.devicePixelRatio || 1, 2)\n });\n gl = renderer.gl;\n } catch (error) {\n console.warn('WarpText: WebGL could not be initialized.', error);\n return undefined;\n }\n\n gl.clearColor(0, 0, 0, 0);\n const canvas = gl.canvas;\n canvas.style.position = 'absolute';\n canvas.style.inset = '0';\n canvas.style.width = '100%';\n canvas.style.height = '100%';\n canvas.style.display = 'block';\n canvas.setAttribute('aria-hidden', 'true');\n container.appendChild(canvas);\n\n texture = new Texture(gl, {\n generateMipmaps: false,\n minFilter: gl.LINEAR,\n magFilter: gl.LINEAR,\n wrapS: gl.CLAMP_TO_EDGE,\n wrapT: gl.CLAMP_TO_EDGE\n });\n\n geometry = new Triangle(gl);\n program = new Program(gl, {\n vertex,\n fragment,\n transparent: true,\n depthTest: false,\n depthWrite: false,\n uniforms: {\n uTextTexture: { value: texture },\n uResolution: { value: new Float32Array([1, 1]) },\n uPointer: { value: new Float32Array([0.5, 0.5]) },\n uPointerActive: { value: 0 },\n uTime: { value: 0 },\n uWarpStrength: { value: propsRef.current.warpStrength },\n uWarpScale: { value: propsRef.current.warpScale },\n uSpeed: { value: propsRef.current.speed },\n uPointerInfluence: { value: propsRef.current.pointerInfluence },\n uPointerStrength: { value: propsRef.current.pointerStrength },\n uRefraction: { value: propsRef.current.refraction },\n uRipple: { value: propsRef.current.ripple ? 1 : 0 },\n uMotion: { value: reduceMotion ? 0 : 1 }\n }\n });\n mesh = new Mesh(gl, { geometry, program });\n\n const renderOnce = () => {\n if (disposed || contextLost) return;\n renderer.render({ scene: mesh });\n };\n\n const rasterize = async () => {\n const version = ++rasterVersion;\n if (document.fonts?.ready) {\n try {\n await document.fonts.ready;\n } catch (error) {\n void error;\n }\n }\n if (disposed || contextLost || version !== rasterVersion) return;\n\n const rect = container.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n const textCanvas = buildTextCanvas({\n container,\n width: rect.width,\n height: rect.height,\n dpr,\n props: propsRef.current\n });\n texture.image = textCanvas;\n texture.needsUpdate = true;\n renderOnce();\n };\n\n const resize = () => {\n if (disposed || contextLost) return;\n const rect = container.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n\n renderer.dpr = Math.min(window.devicePixelRatio || 1, 2);\n renderer.setSize(rect.width, rect.height);\n program.uniforms.uResolution.value[0] = gl.drawingBufferWidth;\n program.uniforms.uResolution.value[1] = gl.drawingBufferHeight;\n rasterize();\n };\n\n const onPointerMove = (event: PointerEvent): void => {\n if (event.pointerType === 'touch') return;\n const rect = canvas.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n pointer.tx = (event.clientX - rect.left) / rect.width;\n pointer.ty = 1 - (event.clientY - rect.top) / rect.height;\n pointer.activeTarget = 1;\n };\n\n const onPointerLeave = (): void => {\n pointer.activeTarget = 0;\n };\n\n const onContextLost = (event: Event): void => {\n event.preventDefault();\n contextLost = true;\n if (raf) cancelAnimationFrame(raf);\n raf = 0;\n };\n\n const onVisibility = (): void => {\n pageVisible = !document.hidden;\n if (pageVisible && visible && !raf) raf = requestAnimationFrame(loop);\n if (!pageVisible && raf) {\n cancelAnimationFrame(raf);\n raf = 0;\n }\n };\n\n const mediaQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)');\n const onReducedMotion = (event: MediaQueryListEvent): void => {\n reduceMotion = event.matches;\n program.uniforms.uMotion.value = reduceMotion ? 0 : 1;\n renderOnce();\n };\n\n const loop = (now: number): void => {\n if (disposed || contextLost) return;\n\n const elapsed = (now - startTime) * 0.001;\n const idleX = 0.5 + Math.sin(elapsed * 0.33) * 0.12;\n const idleY = 0.5 + Math.cos(elapsed * 0.27) * 0.1;\n const targetX = pointer.activeTarget > 0 ? pointer.tx : idleX;\n const targetY = pointer.activeTarget > 0 ? pointer.ty : idleY;\n const damping = pointer.activeTarget > 0 ? 0.12 : 0.035;\n\n pointer.x += (targetX - pointer.x) * damping;\n pointer.y += (targetY - pointer.y) * damping;\n pointer.active += ((pointer.activeTarget > 0 ? 1 : 0.18) - pointer.active) * 0.06;\n\n program.uniforms.uPointer.value[0] = pointer.x;\n program.uniforms.uPointer.value[1] = pointer.y;\n program.uniforms.uPointerActive.value = reduceMotion ? pointer.active * 0.35 : pointer.active;\n program.uniforms.uTime.value = reduceMotion ? 0 : elapsed;\n\n renderOnce();\n raf = requestAnimationFrame(loop);\n };\n\n resizeObserver = new ResizeObserver(resize);\n resizeObserver.observe(container);\n\n intersectionObserver = new IntersectionObserver(\n ([entry]: IntersectionObserverEntry[]) => {\n visible = entry.isIntersecting;\n if (visible && pageVisible && !raf) raf = requestAnimationFrame(loop);\n if (!visible && raf) {\n cancelAnimationFrame(raf);\n raf = 0;\n }\n },\n { threshold: 0 }\n );\n intersectionObserver.observe(container);\n\n canvas.addEventListener('pointermove', onPointerMove);\n canvas.addEventListener('pointerleave', onPointerLeave);\n canvas.addEventListener('webglcontextlost', onContextLost, false);\n document.addEventListener('visibilitychange', onVisibility);\n mediaQuery?.addEventListener('change', onReducedMotion);\n\n syncUniforms(program, propsRef.current);\n contextRef.current = { program, rasterize };\n resize();\n raf = requestAnimationFrame(loop);\n\n return () => {\n disposed = true;\n contextRef.current = null;\n if (raf) cancelAnimationFrame(raf);\n resizeObserver?.disconnect();\n intersectionObserver?.disconnect();\n canvas.removeEventListener('pointermove', onPointerMove);\n canvas.removeEventListener('pointerleave', onPointerLeave);\n canvas.removeEventListener('webglcontextlost', onContextLost);\n document.removeEventListener('visibilitychange', onVisibility);\n mediaQuery?.removeEventListener('change', onReducedMotion);\n\n if (!contextLost) {\n try {\n if (texture?.texture) gl.deleteTexture(texture.texture);\n geometry?.remove?.();\n program?.remove?.();\n gl.getExtension('WEBGL_lose_context')?.loseContext();\n } catch (error) {\n void error;\n }\n }\n\n if (canvas.parentNode === container) container.removeChild(canvas);\n };\n }, []);\n\n return (\n
\n );\n};\n\nexport default WarpText;\n" } ], "registryDependencies": [], diff --git a/public/r/WarpText-TS-TW.json b/public/r/WarpText-TS-TW.json index 65c7d7020..3fadfcea6 100644 --- a/public/r/WarpText-TS-TW.json +++ b/public/r/WarpText-TS-TW.json @@ -8,7 +8,7 @@ { "type": "registry:component", "path": "WarpText/WarpText.tsx", - "content": "import { useEffect, useRef, type CSSProperties } from 'react';\nimport { Renderer, Program, Mesh, Triangle, Texture, type OGLRenderingContext } from 'ogl';\n\nexport interface Props {\n text?: string;\n color?: string;\n warpStrength?: number;\n warpScale?: number;\n speed?: number;\n pointerInfluence?: number;\n pointerStrength?: number;\n refraction?: number;\n ripple?: boolean;\n fontSize?: string | number;\n fontWeight?: string | number;\n fontFamily?: string;\n letterSpacing?: string | number;\n lineHeight?: string | number;\n className?: string;\n style?: CSSProperties;\n}\n\ninterface RuntimeProps {\n text: string;\n color: string;\n fontSize: string | number;\n fontWeight: string | number;\n fontFamily: string;\n letterSpacing: string | number;\n lineHeight: string | number;\n warpStrength: number;\n warpScale: number;\n speed: number;\n pointerInfluence: number;\n pointerStrength: number;\n refraction: number;\n ripple: boolean;\n}\n\ninterface RuntimeContext {\n program: Program;\n rasterize: () => void;\n}\n\ninterface BuildTextCanvasArgs {\n container: HTMLElement;\n width: number;\n height: number;\n dpr: number;\n props: RuntimeProps;\n}\n\nconst vertex = `#version 300 es\nin vec2 position;\nin vec2 uv;\nout vec2 vUv;\nvoid main() {\n vUv = uv;\n gl_Position = vec4(position, 0.0, 1.0);\n}\n`;\n\nconst fragment = `#version 300 es\nprecision highp float;\n\nuniform sampler2D uTextTexture;\nuniform vec2 uResolution;\nuniform vec2 uPointer;\nuniform float uPointerActive;\nuniform float uTime;\nuniform float uWarpStrength;\nuniform float uWarpScale;\nuniform float uSpeed;\nuniform float uPointerInfluence;\nuniform float uPointerStrength;\nuniform float uRefraction;\nuniform float uRipple;\nuniform float uMotion;\n\nin vec2 vUv;\nout vec4 fragColor;\n\nfloat hash(vec2 p) {\n p = fract(p * vec2(123.34, 456.21));\n p += dot(p, p + 45.32);\n return fract(p.x * p.y);\n}\n\nfloat noise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n\n float a = hash(i);\n float b = hash(i + vec2(1.0, 0.0));\n float c = hash(i + vec2(0.0, 1.0));\n float d = hash(i + vec2(1.0, 1.0));\n\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfloat fbm(vec2 p) {\n float value = 0.0;\n float amplitude = 0.5;\n for (int i = 0; i < 4; i++) {\n value += amplitude * noise(p);\n p *= 2.02;\n amplitude *= 0.5;\n }\n return value;\n}\n\nvec4 sampleText(vec2 uv) {\n if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {\n return vec4(0.0);\n }\n return texture(uTextTexture, uv);\n}\n\nvoid main() {\n vec2 uv = vUv;\n float aspect = uResolution.x / max(uResolution.y, 1.0);\n float time = uTime * uSpeed;\n float scale = max(uWarpScale, 0.001);\n\n vec2 drift = vec2(time * 0.055, -time * 0.045);\n float n1 = fbm(uv * scale * 3.1 + drift);\n float n2 = fbm((uv + 19.17) * scale * 3.4 - drift.yx);\n vec2 ambient = (vec2(n1, n2) - 0.5) * uWarpStrength * 0.045 * uMotion;\n\n vec2 pointerDelta = uv - uPointer;\n vec2 aspectDelta = vec2(pointerDelta.x * aspect, pointerDelta.y);\n float dist = length(aspectDelta);\n float radius = max(uPointerInfluence, 0.001);\n float t = clamp(dist / radius, 0.0, 1.0);\n float lens = smoothstep(radius, 0.0, dist) * uPointerActive;\n float bulge = t * (1.0 - t) * (1.0 - t) * 6.75 * uPointerActive;\n vec2 dir = dist > 0.0001 ? vec2(aspectDelta.x / aspect, aspectDelta.y) / dist : vec2(0.0);\n\n float rippleWave = sin(dist * 28.0 - time * 4.2) * 0.5 + 0.5;\n float rippleRing = (rippleWave - 0.5) * uRipple;\n vec2 pointerWarp = -dir * bulge * uPointerStrength * 0.045;\n pointerWarp += dir * rippleRing * bulge * uPointerStrength * 0.016;\n\n vec2 displaced = uv + ambient + pointerWarp;\n vec2 splitDir = ambient + pointerWarp;\n float splitLen = length(splitDir);\n splitDir = splitLen > 0.00001 ? splitDir / splitLen : vec2(0.7071, 0.7071);\n vec2 split = splitDir * uRefraction * 0.16 * (0.35 + lens * 1.65);\n\n vec4 base = sampleText(displaced);\n float r = sampleText(displaced + split).r;\n float g = base.g;\n float b = sampleText(displaced - split).b;\n float a = max(max(sampleText(displaced + split).a, base.a), sampleText(displaced - split).a);\n\n vec3 color = vec3(r, g, b) + lens * base.a * 0.055;\n fragColor = vec4(color, a);\n}\n`;\n\nconst getFontValue = (value: string | number): string => (typeof value === 'number' ? `${value}px` : value);\n\nconst measureLine = (ctx: CanvasRenderingContext2D, line: string, letterSpacing: number): number => {\n const chars = Array.from(line);\n const textWidth = chars.reduce((width, char) => width + ctx.measureText(char).width, 0);\n return textWidth + Math.max(0, chars.length - 1) * letterSpacing;\n};\n\nconst drawLine = (ctx: CanvasRenderingContext2D, line: string, x: number, y: number, letterSpacing: number): void => {\n const chars = Array.from(line);\n let cursor = x - measureLine(ctx, line, letterSpacing) / 2;\n\n chars.forEach((char, index) => {\n ctx.fillText(char, cursor, y);\n cursor += ctx.measureText(char).width + (index === chars.length - 1 ? 0 : letterSpacing);\n });\n};\n\nconst buildTextCanvas = ({ container, width, height, dpr, props }: BuildTextCanvasArgs): HTMLCanvasElement => {\n const canvas = document.createElement('canvas');\n canvas.width = Math.max(1, Math.floor(width * dpr));\n canvas.height = Math.max(1, Math.floor(height * dpr));\n\n const ctx = canvas.getContext('2d');\n if (!ctx) return canvas;\n\n const probe = document.createElement('span');\n probe.textContent = props.text;\n Object.assign(probe.style, {\n position: 'absolute',\n visibility: 'hidden',\n pointerEvents: 'none',\n whiteSpace: 'pre',\n inset: '0 auto auto 0',\n fontFamily: props.fontFamily,\n fontSize: getFontValue(props.fontSize),\n fontWeight: String(props.fontWeight),\n letterSpacing: getFontValue(props.letterSpacing),\n lineHeight: typeof props.lineHeight === 'number' ? String(props.lineHeight) : props.lineHeight\n });\n container.appendChild(probe);\n const computed = window.getComputedStyle(probe);\n let fontSizePx = parseFloat(computed.fontSize) || 96;\n const fontFamily = computed.fontFamily || 'sans-serif';\n const fontWeight = computed.fontWeight || String(props.fontWeight);\n let letterSpacing = computed.letterSpacing === 'normal' ? 0 : parseFloat(computed.letterSpacing) || 0;\n let lineHeight = parseFloat(computed.lineHeight);\n if (!Number.isFinite(lineHeight)) {\n lineHeight = fontSizePx * (typeof props.lineHeight === 'number' ? props.lineHeight : 0.92);\n }\n probe.remove();\n\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n ctx.clearRect(0, 0, width, height);\n ctx.textAlign = 'left';\n ctx.textBaseline = 'middle';\n ctx.fillStyle = props.color;\n ctx.imageSmoothingEnabled = true;\n ctx.imageSmoothingQuality = 'high';\n\n const lines = String(props.text || '').split('\\n');\n const applyFont = () => {\n ctx.font = `${fontWeight} ${fontSizePx}px ${fontFamily}`;\n };\n applyFont();\n\n const maxWidth = width * 0.86;\n const maxHeight = height * 0.78;\n const widest = Math.max(...lines.map(line => measureLine(ctx, line, letterSpacing)), 1);\n const blockHeight = Math.max(lineHeight * lines.length, 1);\n const fit = Math.min(1, maxWidth / widest, maxHeight / blockHeight);\n\n if (fit < 1) {\n fontSizePx *= fit;\n letterSpacing *= fit;\n lineHeight *= fit;\n applyFont();\n }\n\n const startY = height / 2 - (lineHeight * (lines.length - 1)) / 2;\n lines.forEach((line, index) => drawLine(ctx, line, width / 2, startY + index * lineHeight, letterSpacing));\n\n return canvas;\n};\n\nconst syncUniforms = (program: Program, props: RuntimeProps): void => {\n const uniforms = program.uniforms;\n uniforms.uWarpStrength.value = props.warpStrength;\n uniforms.uWarpScale.value = props.warpScale;\n uniforms.uSpeed.value = props.speed;\n uniforms.uPointerInfluence.value = props.pointerInfluence;\n uniforms.uPointerStrength.value = props.pointerStrength;\n uniforms.uRefraction.value = props.refraction;\n uniforms.uRipple.value = props.ripple ? 1 : 0;\n};\n\nconst WarpText = ({\n text = 'Bend the moment',\n color = '#f8f5ff',\n warpStrength = 0.08,\n warpScale = 1.7,\n speed = 0.55,\n pointerInfluence = 0.42,\n pointerStrength = 0.38,\n refraction = 0.018,\n ripple = true,\n fontSize = 'clamp(3rem, 10vw, 9rem)',\n fontWeight = 800,\n fontFamily = 'inherit',\n letterSpacing = '-0.06em',\n lineHeight = 0.9,\n className = '',\n style\n}: Props) => {\n const containerRef = useRef(null);\n const propsRef = useRef({\n text,\n color,\n fontSize,\n fontWeight,\n fontFamily,\n letterSpacing,\n lineHeight,\n warpStrength,\n warpScale,\n speed,\n pointerInfluence,\n pointerStrength,\n refraction,\n ripple\n });\n const contextRef = useRef(null);\n\n useEffect(() => {\n propsRef.current = {\n text,\n color,\n fontSize,\n fontWeight,\n fontFamily,\n letterSpacing,\n lineHeight,\n warpStrength,\n warpScale,\n speed,\n pointerInfluence,\n pointerStrength,\n refraction,\n ripple\n };\n\n if (contextRef.current) {\n syncUniforms(contextRef.current.program, propsRef.current);\n contextRef.current.rasterize();\n }\n }, [\n text,\n color,\n fontSize,\n fontWeight,\n fontFamily,\n letterSpacing,\n lineHeight,\n warpStrength,\n warpScale,\n speed,\n pointerInfluence,\n pointerStrength,\n refraction,\n ripple\n ]);\n\n useEffect(() => {\n const container = containerRef.current;\n if (!container || typeof window === 'undefined') return undefined;\n\n let renderer: Renderer;\n let gl: OGLRenderingContext;\n let program: Program;\n let geometry: Triangle;\n let mesh: Mesh;\n let texture: Texture;\n let resizeObserver: ResizeObserver | null = null;\n let intersectionObserver: IntersectionObserver | null = null;\n let raf = 0;\n let disposed = false;\n let contextLost = false;\n let visible = true;\n let pageVisible = !document.hidden;\n let reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;\n let rasterVersion = 0;\n\n const pointer = { x: 0.5, y: 0.5, tx: 0.5, ty: 0.5, active: 0, activeTarget: 0 };\n const startTime = performance.now();\n\n try {\n renderer = new Renderer({\n webgl: 2,\n alpha: true,\n premultipliedAlpha: false,\n antialias: true,\n dpr: Math.min(window.devicePixelRatio || 1, 2)\n });\n gl = renderer.gl;\n } catch (error) {\n console.warn('WarpText: WebGL could not be initialized.', error);\n return undefined;\n }\n\n gl.clearColor(0, 0, 0, 0);\n const canvas = gl.canvas;\n canvas.style.position = 'absolute';\n canvas.style.inset = '0';\n canvas.style.width = '100%';\n canvas.style.height = '100%';\n canvas.style.display = 'block';\n canvas.setAttribute('aria-hidden', 'true');\n container.appendChild(canvas);\n\n texture = new Texture(gl, {\n generateMipmaps: false,\n minFilter: gl.LINEAR,\n magFilter: gl.LINEAR,\n wrapS: gl.CLAMP_TO_EDGE,\n wrapT: gl.CLAMP_TO_EDGE\n });\n\n geometry = new Triangle(gl);\n program = new Program(gl, {\n vertex,\n fragment,\n transparent: true,\n depthTest: false,\n depthWrite: false,\n uniforms: {\n uTextTexture: { value: texture },\n uResolution: { value: new Float32Array([1, 1]) },\n uPointer: { value: new Float32Array([0.5, 0.5]) },\n uPointerActive: { value: 0 },\n uTime: { value: 0 },\n uWarpStrength: { value: propsRef.current.warpStrength },\n uWarpScale: { value: propsRef.current.warpScale },\n uSpeed: { value: propsRef.current.speed },\n uPointerInfluence: { value: propsRef.current.pointerInfluence },\n uPointerStrength: { value: propsRef.current.pointerStrength },\n uRefraction: { value: propsRef.current.refraction },\n uRipple: { value: propsRef.current.ripple ? 1 : 0 },\n uMotion: { value: reduceMotion ? 0 : 1 }\n }\n });\n mesh = new Mesh(gl, { geometry, program });\n\n const renderOnce = () => {\n if (disposed || contextLost) return;\n renderer.render({ scene: mesh });\n };\n\n const rasterize = async () => {\n const version = ++rasterVersion;\n if (document.fonts?.ready) {\n try {\n await document.fonts.ready;\n } catch {}\n }\n if (disposed || contextLost || version !== rasterVersion) return;\n\n const rect = container.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n const textCanvas = buildTextCanvas({\n container,\n width: rect.width,\n height: rect.height,\n dpr,\n props: propsRef.current\n });\n texture.image = textCanvas;\n texture.needsUpdate = true;\n renderOnce();\n };\n\n const resize = () => {\n if (disposed || contextLost) return;\n const rect = container.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n\n renderer.dpr = Math.min(window.devicePixelRatio || 1, 2);\n renderer.setSize(rect.width, rect.height);\n program.uniforms.uResolution.value[0] = gl.drawingBufferWidth;\n program.uniforms.uResolution.value[1] = gl.drawingBufferHeight;\n rasterize();\n };\n\n const onPointerMove = (event: PointerEvent): void => {\n if (event.pointerType === 'touch') return;\n const rect = canvas.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n pointer.tx = (event.clientX - rect.left) / rect.width;\n pointer.ty = 1 - (event.clientY - rect.top) / rect.height;\n pointer.activeTarget = 1;\n };\n\n const onPointerLeave = (): void => {\n pointer.activeTarget = 0;\n };\n\n const onContextLost = (event: Event): void => {\n event.preventDefault();\n contextLost = true;\n if (raf) cancelAnimationFrame(raf);\n raf = 0;\n };\n\n const onVisibility = (): void => {\n pageVisible = !document.hidden;\n if (pageVisible && visible && !raf) raf = requestAnimationFrame(loop);\n if (!pageVisible && raf) {\n cancelAnimationFrame(raf);\n raf = 0;\n }\n };\n\n const mediaQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)');\n const onReducedMotion = (event: MediaQueryListEvent): void => {\n reduceMotion = event.matches;\n program.uniforms.uMotion.value = reduceMotion ? 0 : 1;\n renderOnce();\n };\n\n const loop = (now: number): void => {\n if (disposed || contextLost) return;\n\n const elapsed = (now - startTime) * 0.001;\n const idleX = 0.5 + Math.sin(elapsed * 0.33) * 0.12;\n const idleY = 0.5 + Math.cos(elapsed * 0.27) * 0.1;\n const targetX = pointer.activeTarget > 0 ? pointer.tx : idleX;\n const targetY = pointer.activeTarget > 0 ? pointer.ty : idleY;\n const damping = pointer.activeTarget > 0 ? 0.12 : 0.035;\n\n pointer.x += (targetX - pointer.x) * damping;\n pointer.y += (targetY - pointer.y) * damping;\n pointer.active += ((pointer.activeTarget > 0 ? 1 : 0.18) - pointer.active) * 0.06;\n\n program.uniforms.uPointer.value[0] = pointer.x;\n program.uniforms.uPointer.value[1] = pointer.y;\n program.uniforms.uPointerActive.value = reduceMotion ? pointer.active * 0.35 : pointer.active;\n program.uniforms.uTime.value = reduceMotion ? 0 : elapsed;\n\n renderOnce();\n raf = requestAnimationFrame(loop);\n };\n\n resizeObserver = new ResizeObserver(resize);\n resizeObserver.observe(container);\n\n intersectionObserver = new IntersectionObserver(\n ([entry]: IntersectionObserverEntry[]) => {\n visible = entry.isIntersecting;\n if (visible && pageVisible && !raf) raf = requestAnimationFrame(loop);\n if (!visible && raf) {\n cancelAnimationFrame(raf);\n raf = 0;\n }\n },\n { threshold: 0 }\n );\n intersectionObserver.observe(container);\n\n canvas.addEventListener('pointermove', onPointerMove);\n canvas.addEventListener('pointerleave', onPointerLeave);\n canvas.addEventListener('webglcontextlost', onContextLost, false);\n document.addEventListener('visibilitychange', onVisibility);\n mediaQuery?.addEventListener('change', onReducedMotion);\n\n syncUniforms(program, propsRef.current);\n contextRef.current = { program, rasterize };\n resize();\n raf = requestAnimationFrame(loop);\n\n return () => {\n disposed = true;\n contextRef.current = null;\n if (raf) cancelAnimationFrame(raf);\n resizeObserver?.disconnect();\n intersectionObserver?.disconnect();\n canvas.removeEventListener('pointermove', onPointerMove);\n canvas.removeEventListener('pointerleave', onPointerLeave);\n canvas.removeEventListener('webglcontextlost', onContextLost);\n document.removeEventListener('visibilitychange', onVisibility);\n mediaQuery?.removeEventListener('change', onReducedMotion);\n\n if (!contextLost) {\n try {\n if (texture?.texture) gl.deleteTexture(texture.texture);\n geometry?.remove?.();\n program?.remove?.();\n gl.getExtension('WEBGL_lose_context')?.loseContext();\n } catch {}\n }\n\n if (canvas.parentNode === container) container.removeChild(canvas);\n };\n }, []);\n\n return (\n \n );\n};\n\nexport default WarpText;\n" + "content": "import { useEffect, useRef, type CSSProperties } from 'react';\nimport { Renderer, Program, Mesh, Triangle, Texture, type OGLRenderingContext } from 'ogl';\n\nexport interface Props {\n text?: string;\n color?: string;\n warpStrength?: number;\n warpScale?: number;\n speed?: number;\n pointerInfluence?: number;\n pointerStrength?: number;\n refraction?: number;\n ripple?: boolean;\n fontSize?: string | number;\n fontWeight?: string | number;\n fontFamily?: string;\n letterSpacing?: string | number;\n lineHeight?: string | number;\n className?: string;\n style?: CSSProperties;\n}\n\ninterface RuntimeProps {\n text: string;\n color: string;\n fontSize: string | number;\n fontWeight: string | number;\n fontFamily: string;\n letterSpacing: string | number;\n lineHeight: string | number;\n warpStrength: number;\n warpScale: number;\n speed: number;\n pointerInfluence: number;\n pointerStrength: number;\n refraction: number;\n ripple: boolean;\n}\n\ninterface RuntimeContext {\n program: Program;\n rasterize: () => void;\n}\n\ninterface BuildTextCanvasArgs {\n container: HTMLElement;\n width: number;\n height: number;\n dpr: number;\n props: RuntimeProps;\n}\n\nconst vertex = `#version 300 es\nin vec2 position;\nin vec2 uv;\nout vec2 vUv;\nvoid main() {\n vUv = uv;\n gl_Position = vec4(position, 0.0, 1.0);\n}\n`;\n\nconst fragment = `#version 300 es\nprecision highp float;\n\nuniform sampler2D uTextTexture;\nuniform vec2 uResolution;\nuniform vec2 uPointer;\nuniform float uPointerActive;\nuniform float uTime;\nuniform float uWarpStrength;\nuniform float uWarpScale;\nuniform float uSpeed;\nuniform float uPointerInfluence;\nuniform float uPointerStrength;\nuniform float uRefraction;\nuniform float uRipple;\nuniform float uMotion;\n\nin vec2 vUv;\nout vec4 fragColor;\n\nfloat hash(vec2 p) {\n p = fract(p * vec2(123.34, 456.21));\n p += dot(p, p + 45.32);\n return fract(p.x * p.y);\n}\n\nfloat noise(vec2 p) {\n vec2 i = floor(p);\n vec2 f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n\n float a = hash(i);\n float b = hash(i + vec2(1.0, 0.0));\n float c = hash(i + vec2(0.0, 1.0));\n float d = hash(i + vec2(1.0, 1.0));\n\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\n\nfloat fbm(vec2 p) {\n float value = 0.0;\n float amplitude = 0.5;\n for (int i = 0; i < 4; i++) {\n value += amplitude * noise(p);\n p *= 2.02;\n amplitude *= 0.5;\n }\n return value;\n}\n\nvec4 sampleText(vec2 uv) {\n if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {\n return vec4(0.0);\n }\n return texture(uTextTexture, uv);\n}\n\nvoid main() {\n vec2 uv = vUv;\n float aspect = uResolution.x / max(uResolution.y, 1.0);\n float time = uTime * uSpeed;\n float scale = max(uWarpScale, 0.001);\n\n vec2 drift = vec2(time * 0.055, -time * 0.045);\n float n1 = fbm(uv * scale * 3.1 + drift);\n float n2 = fbm((uv + 19.17) * scale * 3.4 - drift.yx);\n vec2 ambient = (vec2(n1, n2) - 0.5) * uWarpStrength * 0.045 * uMotion;\n\n vec2 pointerDelta = uv - uPointer;\n vec2 aspectDelta = vec2(pointerDelta.x * aspect, pointerDelta.y);\n float dist = length(aspectDelta);\n float radius = max(uPointerInfluence, 0.001);\n float t = clamp(dist / radius, 0.0, 1.0);\n float lens = smoothstep(radius, 0.0, dist) * uPointerActive;\n float bulge = t * (1.0 - t) * (1.0 - t) * 6.75 * uPointerActive;\n vec2 dir = dist > 0.0001 ? vec2(aspectDelta.x / aspect, aspectDelta.y) / dist : vec2(0.0);\n\n float rippleWave = sin(dist * 28.0 - time * 4.2) * 0.5 + 0.5;\n float rippleRing = (rippleWave - 0.5) * uRipple;\n vec2 pointerWarp = -dir * bulge * uPointerStrength * 0.045;\n pointerWarp += dir * rippleRing * bulge * uPointerStrength * 0.016;\n\n vec2 displaced = uv + ambient + pointerWarp;\n vec2 splitDir = ambient + pointerWarp;\n float splitLen = length(splitDir);\n splitDir = splitLen > 0.00001 ? splitDir / splitLen : vec2(0.7071, 0.7071);\n vec2 split = splitDir * uRefraction * 0.16 * (0.35 + lens * 1.65);\n\n vec4 base = sampleText(displaced);\n float r = sampleText(displaced + split).r;\n float g = base.g;\n float b = sampleText(displaced - split).b;\n float a = max(max(sampleText(displaced + split).a, base.a), sampleText(displaced - split).a);\n\n vec3 color = vec3(r, g, b) + lens * base.a * 0.055;\n fragColor = vec4(color, a);\n}\n`;\n\nconst getFontValue = (value: string | number): string => (typeof value === 'number' ? `${value}px` : value);\n\nconst measureLine = (ctx: CanvasRenderingContext2D, line: string, letterSpacing: number): number => {\n const chars = Array.from(line);\n const textWidth = chars.reduce((width, char) => width + ctx.measureText(char).width, 0);\n return textWidth + Math.max(0, chars.length - 1) * letterSpacing;\n};\n\nconst drawLine = (ctx: CanvasRenderingContext2D, line: string, x: number, y: number, letterSpacing: number): void => {\n const chars = Array.from(line);\n let cursor = x - measureLine(ctx, line, letterSpacing) / 2;\n\n chars.forEach((char, index) => {\n ctx.fillText(char, cursor, y);\n cursor += ctx.measureText(char).width + (index === chars.length - 1 ? 0 : letterSpacing);\n });\n};\n\nconst buildTextCanvas = ({ container, width, height, dpr, props }: BuildTextCanvasArgs): HTMLCanvasElement => {\n const canvas = document.createElement('canvas');\n canvas.width = Math.max(1, Math.floor(width * dpr));\n canvas.height = Math.max(1, Math.floor(height * dpr));\n\n const ctx = canvas.getContext('2d');\n if (!ctx) return canvas;\n\n const probe = document.createElement('span');\n probe.textContent = props.text;\n Object.assign(probe.style, {\n position: 'absolute',\n visibility: 'hidden',\n pointerEvents: 'none',\n whiteSpace: 'pre',\n inset: '0 auto auto 0',\n fontFamily: props.fontFamily,\n fontSize: getFontValue(props.fontSize),\n fontWeight: String(props.fontWeight),\n letterSpacing: getFontValue(props.letterSpacing),\n lineHeight: typeof props.lineHeight === 'number' ? String(props.lineHeight) : props.lineHeight\n });\n container.appendChild(probe);\n const computed = window.getComputedStyle(probe);\n let fontSizePx = parseFloat(computed.fontSize) || 96;\n const fontFamily = computed.fontFamily || 'sans-serif';\n const fontWeight = computed.fontWeight || String(props.fontWeight);\n let letterSpacing = computed.letterSpacing === 'normal' ? 0 : parseFloat(computed.letterSpacing) || 0;\n let lineHeight = parseFloat(computed.lineHeight);\n if (!Number.isFinite(lineHeight)) {\n lineHeight = fontSizePx * (typeof props.lineHeight === 'number' ? props.lineHeight : 0.92);\n }\n probe.remove();\n\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n ctx.clearRect(0, 0, width, height);\n ctx.textAlign = 'left';\n ctx.textBaseline = 'middle';\n ctx.fillStyle = props.color;\n ctx.imageSmoothingEnabled = true;\n ctx.imageSmoothingQuality = 'high';\n\n const lines = String(props.text || '').split('\\n');\n const applyFont = () => {\n ctx.font = `${fontWeight} ${fontSizePx}px ${fontFamily}`;\n };\n applyFont();\n\n const maxWidth = width * 0.86;\n const maxHeight = height * 0.78;\n const widest = Math.max(...lines.map(line => measureLine(ctx, line, letterSpacing)), 1);\n const blockHeight = Math.max(lineHeight * lines.length, 1);\n const fit = Math.min(1, maxWidth / widest, maxHeight / blockHeight);\n\n if (fit < 1) {\n fontSizePx *= fit;\n letterSpacing *= fit;\n lineHeight *= fit;\n applyFont();\n }\n\n const startY = height / 2 - (lineHeight * (lines.length - 1)) / 2;\n lines.forEach((line, index) => drawLine(ctx, line, width / 2, startY + index * lineHeight, letterSpacing));\n\n return canvas;\n};\n\nconst syncUniforms = (program: Program, props: RuntimeProps): void => {\n const uniforms = program.uniforms;\n uniforms.uWarpStrength.value = props.warpStrength;\n uniforms.uWarpScale.value = props.warpScale;\n uniforms.uSpeed.value = props.speed;\n uniforms.uPointerInfluence.value = props.pointerInfluence;\n uniforms.uPointerStrength.value = props.pointerStrength;\n uniforms.uRefraction.value = props.refraction;\n uniforms.uRipple.value = props.ripple ? 1 : 0;\n};\n\nconst WarpText = ({\n text = 'Bend the moment',\n color = '#f8f5ff',\n warpStrength = 0.08,\n warpScale = 1.7,\n speed = 0.55,\n pointerInfluence = 0.42,\n pointerStrength = 0.38,\n refraction = 0.018,\n ripple = true,\n fontSize = 'clamp(3rem, 10vw, 9rem)',\n fontWeight = 800,\n fontFamily = 'inherit',\n letterSpacing = '-0.06em',\n lineHeight = 0.9,\n className = '',\n style\n}: Props) => {\n const containerRef = useRef(null);\n const propsRef = useRef({\n text,\n color,\n fontSize,\n fontWeight,\n fontFamily,\n letterSpacing,\n lineHeight,\n warpStrength,\n warpScale,\n speed,\n pointerInfluence,\n pointerStrength,\n refraction,\n ripple\n });\n const contextRef = useRef(null);\n\n useEffect(() => {\n propsRef.current = {\n text,\n color,\n fontSize,\n fontWeight,\n fontFamily,\n letterSpacing,\n lineHeight,\n warpStrength,\n warpScale,\n speed,\n pointerInfluence,\n pointerStrength,\n refraction,\n ripple\n };\n\n if (contextRef.current) {\n syncUniforms(contextRef.current.program, propsRef.current);\n contextRef.current.rasterize();\n }\n }, [\n text,\n color,\n fontSize,\n fontWeight,\n fontFamily,\n letterSpacing,\n lineHeight,\n warpStrength,\n warpScale,\n speed,\n pointerInfluence,\n pointerStrength,\n refraction,\n ripple\n ]);\n\n useEffect(() => {\n const container = containerRef.current;\n if (!container || typeof window === 'undefined') return undefined;\n\n let renderer: Renderer;\n let gl: OGLRenderingContext;\n let program: Program;\n let geometry: Triangle;\n let mesh: Mesh;\n let texture: Texture;\n let resizeObserver: ResizeObserver | null = null;\n let intersectionObserver: IntersectionObserver | null = null;\n let raf = 0;\n let disposed = false;\n let contextLost = false;\n let visible = true;\n let pageVisible = !document.hidden;\n let reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;\n let rasterVersion = 0;\n\n const pointer = { x: 0.5, y: 0.5, tx: 0.5, ty: 0.5, active: 0, activeTarget: 0 };\n const startTime = performance.now();\n\n try {\n renderer = new Renderer({\n webgl: 2,\n alpha: true,\n premultipliedAlpha: false,\n antialias: true,\n dpr: Math.min(window.devicePixelRatio || 1, 2)\n });\n gl = renderer.gl;\n } catch (error) {\n console.warn('WarpText: WebGL could not be initialized.', error);\n return undefined;\n }\n\n gl.clearColor(0, 0, 0, 0);\n const canvas = gl.canvas;\n canvas.style.position = 'absolute';\n canvas.style.inset = '0';\n canvas.style.width = '100%';\n canvas.style.height = '100%';\n canvas.style.display = 'block';\n canvas.setAttribute('aria-hidden', 'true');\n container.appendChild(canvas);\n\n texture = new Texture(gl, {\n generateMipmaps: false,\n minFilter: gl.LINEAR,\n magFilter: gl.LINEAR,\n wrapS: gl.CLAMP_TO_EDGE,\n wrapT: gl.CLAMP_TO_EDGE\n });\n\n geometry = new Triangle(gl);\n program = new Program(gl, {\n vertex,\n fragment,\n transparent: true,\n depthTest: false,\n depthWrite: false,\n uniforms: {\n uTextTexture: { value: texture },\n uResolution: { value: new Float32Array([1, 1]) },\n uPointer: { value: new Float32Array([0.5, 0.5]) },\n uPointerActive: { value: 0 },\n uTime: { value: 0 },\n uWarpStrength: { value: propsRef.current.warpStrength },\n uWarpScale: { value: propsRef.current.warpScale },\n uSpeed: { value: propsRef.current.speed },\n uPointerInfluence: { value: propsRef.current.pointerInfluence },\n uPointerStrength: { value: propsRef.current.pointerStrength },\n uRefraction: { value: propsRef.current.refraction },\n uRipple: { value: propsRef.current.ripple ? 1 : 0 },\n uMotion: { value: reduceMotion ? 0 : 1 }\n }\n });\n mesh = new Mesh(gl, { geometry, program });\n\n const renderOnce = () => {\n if (disposed || contextLost) return;\n renderer.render({ scene: mesh });\n };\n\n const rasterize = async () => {\n const version = ++rasterVersion;\n if (document.fonts?.ready) {\n try {\n await document.fonts.ready;\n } catch (error) {\n void error;\n }\n }\n if (disposed || contextLost || version !== rasterVersion) return;\n\n const rect = container.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n const textCanvas = buildTextCanvas({\n container,\n width: rect.width,\n height: rect.height,\n dpr,\n props: propsRef.current\n });\n texture.image = textCanvas;\n texture.needsUpdate = true;\n renderOnce();\n };\n\n const resize = () => {\n if (disposed || contextLost) return;\n const rect = container.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n\n renderer.dpr = Math.min(window.devicePixelRatio || 1, 2);\n renderer.setSize(rect.width, rect.height);\n program.uniforms.uResolution.value[0] = gl.drawingBufferWidth;\n program.uniforms.uResolution.value[1] = gl.drawingBufferHeight;\n rasterize();\n };\n\n const onPointerMove = (event: PointerEvent): void => {\n if (event.pointerType === 'touch') return;\n const rect = canvas.getBoundingClientRect();\n if (rect.width <= 0 || rect.height <= 0) return;\n pointer.tx = (event.clientX - rect.left) / rect.width;\n pointer.ty = 1 - (event.clientY - rect.top) / rect.height;\n pointer.activeTarget = 1;\n };\n\n const onPointerLeave = (): void => {\n pointer.activeTarget = 0;\n };\n\n const onContextLost = (event: Event): void => {\n event.preventDefault();\n contextLost = true;\n if (raf) cancelAnimationFrame(raf);\n raf = 0;\n };\n\n const onVisibility = (): void => {\n pageVisible = !document.hidden;\n if (pageVisible && visible && !raf) raf = requestAnimationFrame(loop);\n if (!pageVisible && raf) {\n cancelAnimationFrame(raf);\n raf = 0;\n }\n };\n\n const mediaQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)');\n const onReducedMotion = (event: MediaQueryListEvent): void => {\n reduceMotion = event.matches;\n program.uniforms.uMotion.value = reduceMotion ? 0 : 1;\n renderOnce();\n };\n\n const loop = (now: number): void => {\n if (disposed || contextLost) return;\n\n const elapsed = (now - startTime) * 0.001;\n const idleX = 0.5 + Math.sin(elapsed * 0.33) * 0.12;\n const idleY = 0.5 + Math.cos(elapsed * 0.27) * 0.1;\n const targetX = pointer.activeTarget > 0 ? pointer.tx : idleX;\n const targetY = pointer.activeTarget > 0 ? pointer.ty : idleY;\n const damping = pointer.activeTarget > 0 ? 0.12 : 0.035;\n\n pointer.x += (targetX - pointer.x) * damping;\n pointer.y += (targetY - pointer.y) * damping;\n pointer.active += ((pointer.activeTarget > 0 ? 1 : 0.18) - pointer.active) * 0.06;\n\n program.uniforms.uPointer.value[0] = pointer.x;\n program.uniforms.uPointer.value[1] = pointer.y;\n program.uniforms.uPointerActive.value = reduceMotion ? pointer.active * 0.35 : pointer.active;\n program.uniforms.uTime.value = reduceMotion ? 0 : elapsed;\n\n renderOnce();\n raf = requestAnimationFrame(loop);\n };\n\n resizeObserver = new ResizeObserver(resize);\n resizeObserver.observe(container);\n\n intersectionObserver = new IntersectionObserver(\n ([entry]: IntersectionObserverEntry[]) => {\n visible = entry.isIntersecting;\n if (visible && pageVisible && !raf) raf = requestAnimationFrame(loop);\n if (!visible && raf) {\n cancelAnimationFrame(raf);\n raf = 0;\n }\n },\n { threshold: 0 }\n );\n intersectionObserver.observe(container);\n\n canvas.addEventListener('pointermove', onPointerMove);\n canvas.addEventListener('pointerleave', onPointerLeave);\n canvas.addEventListener('webglcontextlost', onContextLost, false);\n document.addEventListener('visibilitychange', onVisibility);\n mediaQuery?.addEventListener('change', onReducedMotion);\n\n syncUniforms(program, propsRef.current);\n contextRef.current = { program, rasterize };\n resize();\n raf = requestAnimationFrame(loop);\n\n return () => {\n disposed = true;\n contextRef.current = null;\n if (raf) cancelAnimationFrame(raf);\n resizeObserver?.disconnect();\n intersectionObserver?.disconnect();\n canvas.removeEventListener('pointermove', onPointerMove);\n canvas.removeEventListener('pointerleave', onPointerLeave);\n canvas.removeEventListener('webglcontextlost', onContextLost);\n document.removeEventListener('visibilitychange', onVisibility);\n mediaQuery?.removeEventListener('change', onReducedMotion);\n\n if (!contextLost) {\n try {\n if (texture?.texture) gl.deleteTexture(texture.texture);\n geometry?.remove?.();\n program?.remove?.();\n gl.getExtension('WEBGL_lose_context')?.loseContext();\n } catch (error) {\n void error;\n }\n }\n\n if (canvas.parentNode === container) container.removeChild(canvas);\n };\n }, []);\n\n return (\n \n );\n};\n\nexport default WarpText;\n" } ], "registryDependencies": [], diff --git a/src/content/TextAnimations/Shuffle/Shuffle.jsx b/src/content/TextAnimations/Shuffle/Shuffle.jsx index fbe34a96e..12fa12704 100644 --- a/src/content/TextAnimations/Shuffle/Shuffle.jsx +++ b/src/content/TextAnimations/Shuffle/Shuffle.jsx @@ -117,6 +117,56 @@ const Shuffle = ({ const rolls = Math.max(1, Math.floor(shuffleTimes)); const rand = set => set.charAt(Math.floor(Math.random() * set.length)) || ''; + const isVertical = shuffleDirection === 'up' || shuffleDirection === 'down'; + const metricsContext = isVertical ? document.createElement('canvas').getContext('2d') : null; + + const measureVerticalCell = (node, lineBoxHeight) => { + const computed = window.getComputedStyle(node); + let fontHeight = 0; + + if (metricsContext) { + metricsContext.font = [ + computed.fontStyle, + computed.fontVariant, + computed.fontWeight, + computed.fontSize, + computed.fontFamily + ].join(' '); + + const sample = `${node.textContent || 'M'}${scrambleCharset}`; + const metrics = metricsContext.measureText(sample); + const ascent = metrics.fontBoundingBoxAscent; + const descent = metrics.fontBoundingBoxDescent; + if (Number.isFinite(ascent) && Number.isFinite(descent)) fontHeight = ascent + descent; + } + + if (!fontHeight) { + const probe = node.cloneNode(true); + probe.textContent = `${node.textContent || 'M'}${scrambleCharset}`; + Object.assign(probe.style, { + position: 'absolute', + visibility: 'hidden', + pointerEvents: 'none', + width: 'auto', + height: 'auto', + whiteSpace: 'nowrap', + lineHeight: 'normal', + fontFamily: computed.fontFamily, + fontSize: computed.fontSize, + fontStyle: computed.fontStyle, + fontVariant: computed.fontVariant, + fontWeight: computed.fontWeight, + fontStretch: computed.fontStretch + }); + document.body.appendChild(probe); + fontHeight = probe.getBoundingClientRect().height; + probe.remove(); + } + + const overflow = Math.max(0, Math.ceil(fontHeight - lineBoxHeight)); + const padTop = Math.floor(overflow / 2); + return { cellHeight: lineBoxHeight + overflow, padTop, padBottom: overflow - padTop }; + }; chars.forEach(ch => { const parent = ch.parentElement; @@ -126,19 +176,25 @@ const Shuffle = ({ const h = ch.getBoundingClientRect().height; if (!w) return; + const { cellHeight, padTop, padBottom } = isVertical + ? measureVerticalCell(ch, h) + : { cellHeight: h, padTop: 0, padBottom: 0 }; + const wrap = document.createElement('span'); Object.assign(wrap.style, { display: 'inline-block', overflow: 'hidden', width: w + 'px', - height: shuffleDirection === 'up' || shuffleDirection === 'down' ? h + 'px' : 'auto', + height: isVertical ? cellHeight + 'px' : 'auto', + marginTop: isVertical ? -padTop + 'px' : '0', + marginBottom: isVertical ? -padBottom + 'px' : '0', verticalAlign: 'bottom' }); const inner = document.createElement('span'); Object.assign(inner.style, { display: 'inline-block', - whiteSpace: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'normal' : 'nowrap', + whiteSpace: isVertical ? 'normal' : 'nowrap', willChange: 'transform' }); @@ -147,14 +203,22 @@ const Shuffle = ({ const firstOrig = ch.cloneNode(true); Object.assign(firstOrig.style, { - display: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block', + display: isVertical ? 'flex' : 'inline-block', + alignItems: isVertical ? 'center' : '', + justifyContent: isVertical ? 'center' : '', + height: isVertical ? cellHeight + 'px' : '', + lineHeight: isVertical ? h + 'px' : '', width: w + 'px', textAlign: 'center' }); ch.setAttribute('data-orig', '1'); Object.assign(ch.style, { - display: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block', + display: isVertical ? 'flex' : 'inline-block', + alignItems: isVertical ? 'center' : '', + justifyContent: isVertical ? 'center' : '', + height: isVertical ? cellHeight + 'px' : '', + lineHeight: isVertical ? h + 'px' : '', width: w + 'px', textAlign: 'center' }); @@ -164,7 +228,11 @@ const Shuffle = ({ const c = ch.cloneNode(true); if (scrambleCharset) c.textContent = rand(scrambleCharset); Object.assign(c.style, { - display: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block', + display: isVertical ? 'flex' : 'inline-block', + alignItems: isVertical ? 'center' : '', + justifyContent: isVertical ? 'center' : '', + height: isVertical ? cellHeight + 'px' : '', + lineHeight: isVertical ? h + 'px' : '', width: w + 'px', textAlign: 'center' }); @@ -193,11 +261,11 @@ const Shuffle = ({ startX = 0; finalX = -steps * w; } else if (shuffleDirection === 'down') { - startY = -steps * h; + startY = -steps * cellHeight; finalY = 0; } else if (shuffleDirection === 'up') { startY = 0; - finalY = -steps * h; + finalY = -steps * cellHeight; } if (shuffleDirection === 'left' || shuffleDirection === 'right') { diff --git a/src/content/TextAnimations/WarpText/WarpText.jsx b/src/content/TextAnimations/WarpText/WarpText.jsx index 56b40722b..98a6a5f64 100644 --- a/src/content/TextAnimations/WarpText/WarpText.jsx +++ b/src/content/TextAnimations/WarpText/WarpText.jsx @@ -373,7 +373,9 @@ const WarpText = ({ if (document.fonts?.ready) { try { await document.fonts.ready; - } catch {} + } catch (error) { + void error; + } } if (disposed || contextLost || version !== rasterVersion) return; @@ -509,7 +511,9 @@ const WarpText = ({ geometry?.remove?.(); program?.remove?.(); gl.getExtension('WEBGL_lose_context')?.loseContext(); - } catch {} + } catch (error) { + void error; + } } if (canvas.parentNode === container) container.removeChild(canvas); diff --git a/src/tailwind/TextAnimations/Shuffle/Shuffle.jsx b/src/tailwind/TextAnimations/Shuffle/Shuffle.jsx index af1c75932..78ff83377 100644 --- a/src/tailwind/TextAnimations/Shuffle/Shuffle.jsx +++ b/src/tailwind/TextAnimations/Shuffle/Shuffle.jsx @@ -128,6 +128,56 @@ const Shuffle = ({ const rolls = Math.max(1, Math.floor(shuffleTimes)); const rand = set => set.charAt(Math.floor(Math.random() * set.length)) || ''; + const isVertical = shuffleDirection === 'up' || shuffleDirection === 'down'; + const metricsContext = isVertical ? document.createElement('canvas').getContext('2d') : null; + + const measureVerticalCell = (node, lineBoxHeight) => { + const computed = window.getComputedStyle(node); + let fontHeight = 0; + + if (metricsContext) { + metricsContext.font = [ + computed.fontStyle, + computed.fontVariant, + computed.fontWeight, + computed.fontSize, + computed.fontFamily + ].join(' '); + + const sample = `${node.textContent || 'M'}${scrambleCharset}`; + const metrics = metricsContext.measureText(sample); + const ascent = metrics.fontBoundingBoxAscent; + const descent = metrics.fontBoundingBoxDescent; + if (Number.isFinite(ascent) && Number.isFinite(descent)) fontHeight = ascent + descent; + } + + if (!fontHeight) { + const probe = node.cloneNode(true); + probe.textContent = `${node.textContent || 'M'}${scrambleCharset}`; + Object.assign(probe.style, { + position: 'absolute', + visibility: 'hidden', + pointerEvents: 'none', + width: 'auto', + height: 'auto', + whiteSpace: 'nowrap', + lineHeight: 'normal', + fontFamily: computed.fontFamily, + fontSize: computed.fontSize, + fontStyle: computed.fontStyle, + fontVariant: computed.fontVariant, + fontWeight: computed.fontWeight, + fontStretch: computed.fontStretch + }); + document.body.appendChild(probe); + fontHeight = probe.getBoundingClientRect().height; + probe.remove(); + } + + const overflow = Math.max(0, Math.ceil(fontHeight - lineBoxHeight)); + const padTop = Math.floor(overflow / 2); + return { cellHeight: lineBoxHeight + overflow, padTop, padBottom: overflow - padTop }; + }; chars.forEach(ch => { const parent = ch.parentElement; @@ -137,39 +187,63 @@ const Shuffle = ({ const h = ch.getBoundingClientRect().height; if (!w) return; + const { cellHeight, padTop, padBottom } = isVertical + ? measureVerticalCell(ch, h) + : { cellHeight: h, padTop: 0, padBottom: 0 }; + const wrap = document.createElement('span'); wrap.className = 'inline-block overflow-hidden text-left'; Object.assign(wrap.style, { width: w + 'px', - height: shuffleDirection === 'up' || shuffleDirection === 'down' ? h + 'px' : 'auto', + height: isVertical ? cellHeight + 'px' : 'auto', + marginTop: isVertical ? -padTop + 'px' : '0', + marginBottom: isVertical ? -padBottom + 'px' : '0', verticalAlign: 'bottom' }); const inner = document.createElement('span'); inner.className = 'inline-block will-change-transform origin-left transform-gpu ' + - (shuffleDirection === 'up' || shuffleDirection === 'down' ? 'whitespace-normal' : 'whitespace-nowrap'); + (isVertical ? 'whitespace-normal' : 'whitespace-nowrap'); parent.insertBefore(wrap, ch); wrap.appendChild(inner); const firstOrig = ch.cloneNode(true); - firstOrig.className = - 'text-left ' + (shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block'); - Object.assign(firstOrig.style, { width: w + 'px', fontFamily: computedFont }); + firstOrig.className = 'text-left ' + (isVertical ? 'flex' : 'inline-block'); + Object.assign(firstOrig.style, { + alignItems: isVertical ? 'center' : '', + justifyContent: isVertical ? 'center' : '', + height: isVertical ? cellHeight + 'px' : '', + lineHeight: isVertical ? h + 'px' : '', + width: w + 'px', + fontFamily: computedFont + }); ch.setAttribute('data-orig', '1'); - ch.className = - 'text-left ' + (shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block'); - Object.assign(ch.style, { width: w + 'px', fontFamily: computedFont }); + ch.className = 'text-left ' + (isVertical ? 'flex' : 'inline-block'); + Object.assign(ch.style, { + alignItems: isVertical ? 'center' : '', + justifyContent: isVertical ? 'center' : '', + height: isVertical ? cellHeight + 'px' : '', + lineHeight: isVertical ? h + 'px' : '', + width: w + 'px', + fontFamily: computedFont + }); inner.appendChild(firstOrig); for (let k = 0; k < rolls; k++) { const c = ch.cloneNode(true); if (scrambleCharset) c.textContent = rand(scrambleCharset); - c.className = - 'text-left ' + (shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block'); - Object.assign(c.style, { width: w + 'px', fontFamily: computedFont }); + c.className = 'text-left ' + (isVertical ? 'flex' : 'inline-block'); + Object.assign(c.style, { + alignItems: isVertical ? 'center' : '', + justifyContent: isVertical ? 'center' : '', + height: isVertical ? cellHeight + 'px' : '', + lineHeight: isVertical ? h + 'px' : '', + width: w + 'px', + fontFamily: computedFont + }); inner.appendChild(c); } inner.appendChild(ch); @@ -195,11 +269,11 @@ const Shuffle = ({ startX = 0; finalX = -steps * w; } else if (shuffleDirection === 'down') { - startY = -steps * h; + startY = -steps * cellHeight; finalY = 0; } else if (shuffleDirection === 'up') { startY = 0; - finalY = -steps * h; + finalY = -steps * cellHeight; } if (shuffleDirection === 'left' || shuffleDirection === 'right') { diff --git a/src/tailwind/TextAnimations/WarpText/WarpText.jsx b/src/tailwind/TextAnimations/WarpText/WarpText.jsx index f4849c013..9b41287c0 100644 --- a/src/tailwind/TextAnimations/WarpText/WarpText.jsx +++ b/src/tailwind/TextAnimations/WarpText/WarpText.jsx @@ -372,7 +372,9 @@ const WarpText = ({ if (document.fonts?.ready) { try { await document.fonts.ready; - } catch {} + } catch (error) { + void error; + } } if (disposed || contextLost || version !== rasterVersion) return; @@ -508,7 +510,9 @@ const WarpText = ({ geometry?.remove?.(); program?.remove?.(); gl.getExtension('WEBGL_lose_context')?.loseContext(); - } catch {} + } catch (error) { + void error; + } } if (canvas.parentNode === container) container.removeChild(canvas); diff --git a/src/ts-default/TextAnimations/Shuffle/Shuffle.tsx b/src/ts-default/TextAnimations/Shuffle/Shuffle.tsx index ec4e9cec8..a9591f333 100644 --- a/src/ts-default/TextAnimations/Shuffle/Shuffle.tsx +++ b/src/ts-default/TextAnimations/Shuffle/Shuffle.tsx @@ -139,6 +139,56 @@ const Shuffle: React.FC = ({ const rolls = Math.max(1, Math.floor(shuffleTimes)); const rand = (set: string) => set.charAt(Math.floor(Math.random() * set.length)) || ''; + const isVertical = shuffleDirection === 'up' || shuffleDirection === 'down'; + const metricsContext = isVertical ? document.createElement('canvas').getContext('2d') : null; + + const measureVerticalCell = (node: HTMLElement, lineBoxHeight: number) => { + const computed = window.getComputedStyle(node); + let fontHeight = 0; + + if (metricsContext) { + metricsContext.font = [ + computed.fontStyle, + computed.fontVariant, + computed.fontWeight, + computed.fontSize, + computed.fontFamily + ].join(' '); + + const sample = `${node.textContent || 'M'}${scrambleCharset}`; + const metrics = metricsContext.measureText(sample); + const ascent = metrics.fontBoundingBoxAscent; + const descent = metrics.fontBoundingBoxDescent; + if (Number.isFinite(ascent) && Number.isFinite(descent)) fontHeight = ascent + descent; + } + + if (!fontHeight) { + const probe = node.cloneNode(true) as HTMLElement; + probe.textContent = `${node.textContent || 'M'}${scrambleCharset}`; + Object.assign(probe.style, { + position: 'absolute', + visibility: 'hidden', + pointerEvents: 'none', + width: 'auto', + height: 'auto', + whiteSpace: 'nowrap', + lineHeight: 'normal', + fontFamily: computed.fontFamily, + fontSize: computed.fontSize, + fontStyle: computed.fontStyle, + fontVariant: computed.fontVariant, + fontWeight: computed.fontWeight, + fontStretch: computed.fontStretch + }); + document.body.appendChild(probe); + fontHeight = probe.getBoundingClientRect().height; + probe.remove(); + } + + const overflow = Math.max(0, Math.ceil(fontHeight - lineBoxHeight)); + const padTop = Math.floor(overflow / 2); + return { cellHeight: lineBoxHeight + overflow, padTop, padBottom: overflow - padTop }; + }; chars.forEach(ch => { const parent = ch.parentElement; @@ -148,19 +198,25 @@ const Shuffle: React.FC = ({ const h = ch.getBoundingClientRect().height; if (!w) return; + const { cellHeight, padTop, padBottom } = isVertical + ? measureVerticalCell(ch, h) + : { cellHeight: h, padTop: 0, padBottom: 0 }; + const wrap = document.createElement('span'); Object.assign(wrap.style, { display: 'inline-block', overflow: 'hidden', width: w + 'px', - height: shuffleDirection === 'up' || shuffleDirection === 'down' ? h + 'px' : 'auto', + height: isVertical ? cellHeight + 'px' : 'auto', + marginTop: isVertical ? -padTop + 'px' : '0', + marginBottom: isVertical ? -padBottom + 'px' : '0', verticalAlign: 'bottom' }); const inner = document.createElement('span'); Object.assign(inner.style, { display: 'inline-block', - whiteSpace: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'normal' : 'nowrap', + whiteSpace: isVertical ? 'normal' : 'nowrap', willChange: 'transform' }); @@ -169,14 +225,22 @@ const Shuffle: React.FC = ({ const firstOrig = ch.cloneNode(true) as HTMLElement; Object.assign(firstOrig.style, { - display: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block', + display: isVertical ? 'flex' : 'inline-block', + alignItems: isVertical ? 'center' : '', + justifyContent: isVertical ? 'center' : '', + height: isVertical ? cellHeight + 'px' : '', + lineHeight: isVertical ? h + 'px' : '', width: w + 'px', textAlign: 'center' }); ch.setAttribute('data-orig', '1'); Object.assign(ch.style, { - display: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block', + display: isVertical ? 'flex' : 'inline-block', + alignItems: isVertical ? 'center' : '', + justifyContent: isVertical ? 'center' : '', + height: isVertical ? cellHeight + 'px' : '', + lineHeight: isVertical ? h + 'px' : '', width: w + 'px', textAlign: 'center' }); @@ -186,7 +250,11 @@ const Shuffle: React.FC = ({ const c = ch.cloneNode(true) as HTMLElement; if (scrambleCharset) c.textContent = rand(scrambleCharset); Object.assign(c.style, { - display: shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block', + display: isVertical ? 'flex' : 'inline-block', + alignItems: isVertical ? 'center' : '', + justifyContent: isVertical ? 'center' : '', + height: isVertical ? cellHeight + 'px' : '', + lineHeight: isVertical ? h + 'px' : '', width: w + 'px', textAlign: 'center' }); @@ -215,11 +283,11 @@ const Shuffle: React.FC = ({ startX = 0; finalX = -steps * w; } else if (shuffleDirection === 'down') { - startY = -steps * h; + startY = -steps * cellHeight; finalY = 0; } else if (shuffleDirection === 'up') { startY = 0; - finalY = -steps * h; + finalY = -steps * cellHeight; } if (shuffleDirection === 'left' || shuffleDirection === 'right') { diff --git a/src/ts-default/TextAnimations/WarpText/WarpText.tsx b/src/ts-default/TextAnimations/WarpText/WarpText.tsx index 3fb165fb4..7965eb830 100644 --- a/src/ts-default/TextAnimations/WarpText/WarpText.tsx +++ b/src/ts-default/TextAnimations/WarpText/WarpText.tsx @@ -422,7 +422,9 @@ const WarpText = ({ if (document.fonts?.ready) { try { await document.fonts.ready; - } catch {} + } catch (error) { + void error; + } } if (disposed || contextLost || version !== rasterVersion) return; @@ -558,7 +560,9 @@ const WarpText = ({ geometry?.remove?.(); program?.remove?.(); gl.getExtension('WEBGL_lose_context')?.loseContext(); - } catch {} + } catch (error) { + void error; + } } if (canvas.parentNode === container) container.removeChild(canvas); diff --git a/src/ts-tailwind/TextAnimations/Shuffle/Shuffle.tsx b/src/ts-tailwind/TextAnimations/Shuffle/Shuffle.tsx index 77481b55e..a638aac91 100644 --- a/src/ts-tailwind/TextAnimations/Shuffle/Shuffle.tsx +++ b/src/ts-tailwind/TextAnimations/Shuffle/Shuffle.tsx @@ -141,6 +141,56 @@ const Shuffle: React.FC = ({ const rolls = Math.max(1, Math.floor(shuffleTimes)); const rand = (set: string) => set.charAt(Math.floor(Math.random() * set.length)) || ''; + const isVertical = shuffleDirection === 'up' || shuffleDirection === 'down'; + const metricsContext = isVertical ? document.createElement('canvas').getContext('2d') : null; + + const measureVerticalCell = (node: HTMLElement, lineBoxHeight: number) => { + const computed = window.getComputedStyle(node); + let fontHeight = 0; + + if (metricsContext) { + metricsContext.font = [ + computed.fontStyle, + computed.fontVariant, + computed.fontWeight, + computed.fontSize, + computed.fontFamily + ].join(' '); + + const sample = `${node.textContent || 'M'}${scrambleCharset}`; + const metrics = metricsContext.measureText(sample); + const ascent = metrics.fontBoundingBoxAscent; + const descent = metrics.fontBoundingBoxDescent; + if (Number.isFinite(ascent) && Number.isFinite(descent)) fontHeight = ascent + descent; + } + + if (!fontHeight) { + const probe = node.cloneNode(true) as HTMLElement; + probe.textContent = `${node.textContent || 'M'}${scrambleCharset}`; + Object.assign(probe.style, { + position: 'absolute', + visibility: 'hidden', + pointerEvents: 'none', + width: 'auto', + height: 'auto', + whiteSpace: 'nowrap', + lineHeight: 'normal', + fontFamily: computed.fontFamily, + fontSize: computed.fontSize, + fontStyle: computed.fontStyle, + fontVariant: computed.fontVariant, + fontWeight: computed.fontWeight, + fontStretch: computed.fontStretch + }); + document.body.appendChild(probe); + fontHeight = probe.getBoundingClientRect().height; + probe.remove(); + } + + const overflow = Math.max(0, Math.ceil(fontHeight - lineBoxHeight)); + const padTop = Math.floor(overflow / 2); + return { cellHeight: lineBoxHeight + overflow, padTop, padBottom: overflow - padTop }; + }; chars.forEach(ch => { const parent = ch.parentElement; @@ -150,39 +200,63 @@ const Shuffle: React.FC = ({ const h = ch.getBoundingClientRect().height; if (!w) return; + const { cellHeight, padTop, padBottom } = isVertical + ? measureVerticalCell(ch, h) + : { cellHeight: h, padTop: 0, padBottom: 0 }; + const wrap = document.createElement('span'); wrap.className = 'inline-block overflow-hidden text-left'; Object.assign(wrap.style, { width: w + 'px', - height: shuffleDirection === 'up' || shuffleDirection === 'down' ? h + 'px' : 'auto', + height: isVertical ? cellHeight + 'px' : 'auto', + marginTop: isVertical ? -padTop + 'px' : '0', + marginBottom: isVertical ? -padBottom + 'px' : '0', verticalAlign: 'bottom' }); const inner = document.createElement('span'); inner.className = 'inline-block will-change-transform origin-left transform-gpu ' + - (shuffleDirection === 'up' || shuffleDirection === 'down' ? 'whitespace-normal' : 'whitespace-nowrap'); + (isVertical ? 'whitespace-normal' : 'whitespace-nowrap'); parent.insertBefore(wrap, ch); wrap.appendChild(inner); const firstOrig = ch.cloneNode(true) as HTMLElement; - firstOrig.className = - 'text-left ' + (shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block'); - Object.assign(firstOrig.style, { width: w + 'px', fontFamily: computedFont }); + firstOrig.className = 'text-left ' + (isVertical ? 'flex' : 'inline-block'); + Object.assign(firstOrig.style, { + alignItems: isVertical ? 'center' : '', + justifyContent: isVertical ? 'center' : '', + height: isVertical ? cellHeight + 'px' : '', + lineHeight: isVertical ? h + 'px' : '', + width: w + 'px', + fontFamily: computedFont + }); ch.setAttribute('data-orig', '1'); - ch.className = - 'text-left ' + (shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block'); - Object.assign(ch.style, { width: w + 'px', fontFamily: computedFont }); + ch.className = 'text-left ' + (isVertical ? 'flex' : 'inline-block'); + Object.assign(ch.style, { + alignItems: isVertical ? 'center' : '', + justifyContent: isVertical ? 'center' : '', + height: isVertical ? cellHeight + 'px' : '', + lineHeight: isVertical ? h + 'px' : '', + width: w + 'px', + fontFamily: computedFont + }); inner.appendChild(firstOrig); for (let k = 0; k < rolls; k++) { const c = ch.cloneNode(true) as HTMLElement; if (scrambleCharset) c.textContent = rand(scrambleCharset); - c.className = - 'text-left ' + (shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block'); - Object.assign(c.style, { width: w + 'px', fontFamily: computedFont }); + c.className = 'text-left ' + (isVertical ? 'flex' : 'inline-block'); + Object.assign(c.style, { + alignItems: isVertical ? 'center' : '', + justifyContent: isVertical ? 'center' : '', + height: isVertical ? cellHeight + 'px' : '', + lineHeight: isVertical ? h + 'px' : '', + width: w + 'px', + fontFamily: computedFont + }); inner.appendChild(c); } inner.appendChild(ch); @@ -208,11 +282,11 @@ const Shuffle: React.FC = ({ startX = 0; finalX = -steps * w; } else if (shuffleDirection === 'down') { - startY = -steps * h; + startY = -steps * cellHeight; finalY = 0; } else if (shuffleDirection === 'up') { startY = 0; - finalY = -steps * h; + finalY = -steps * cellHeight; } if (shuffleDirection === 'left' || shuffleDirection === 'right') { diff --git a/src/ts-tailwind/TextAnimations/WarpText/WarpText.tsx b/src/ts-tailwind/TextAnimations/WarpText/WarpText.tsx index 8db98df30..ec80507eb 100644 --- a/src/ts-tailwind/TextAnimations/WarpText/WarpText.tsx +++ b/src/ts-tailwind/TextAnimations/WarpText/WarpText.tsx @@ -421,7 +421,9 @@ const WarpText = ({ if (document.fonts?.ready) { try { await document.fonts.ready; - } catch {} + } catch (error) { + void error; + } } if (disposed || contextLost || version !== rasterVersion) return; @@ -557,7 +559,9 @@ const WarpText = ({ geometry?.remove?.(); program?.remove?.(); gl.getExtension('WEBGL_lose_context')?.loseContext(); - } catch {} + } catch (error) { + void error; + } } if (canvas.parentNode === container) container.removeChild(canvas);