From e70a574fc995ca15f5cc9b62c571b790a6e3f706 Mon Sep 17 00:00:00 2001 From: FelipeKreulich Date: Sun, 6 Sep 2026 02:43:05 +0100 Subject: [PATCH] fix(LetterGlitch): smooth colour transitions freeze after one frame handleSmoothTransitions parsed letter.color with hexToRgb, but that same field had already been overwritten with interpolateColor's `rgb(r, g, b)` output on the previous frame. hexToRgb matches hex only, returned null, and the guarded branch never ran again: colorProgress kept climbing to 1 while the colour stayed ~5% of the way to its target, and needsRedraw stayed false. In practice `smooth` had almost no effect. Interpolation now happens in numbers and the CSS string is built only at paint time, so the parser is never handed its own output. Each letter keeps rgb (what is painted), fromRgb and targetRgb (the endpoints), which also means a letter picked again mid-fade continues from the colour currently on screen instead of jumping. An unparseable entry in glitchColors previously left fillStyle invalid and the fade frozen; it now falls back to white, which is visible rather than silent. Applied to all four variants, and the registry output regenerated with `npm run registry:build`. --- public/r/LetterGlitch-JS-CSS.json | 2 +- public/r/LetterGlitch-JS-TW.json | 2 +- public/r/LetterGlitch-TS-CSS.json | 2 +- public/r/LetterGlitch-TS-TW.json | 2 +- .../Backgrounds/LetterGlitch/LetterGlitch.jsx | 57 +++++++++------ .../Backgrounds/LetterGlitch/LetterGlitch.jsx | 57 +++++++++------ .../Backgrounds/LetterGlitch/LetterGlitch.tsx | 72 +++++++++++-------- .../Backgrounds/LetterGlitch/LetterGlitch.tsx | 72 +++++++++++-------- 8 files changed, 158 insertions(+), 108 deletions(-) diff --git a/public/r/LetterGlitch-JS-CSS.json b/public/r/LetterGlitch-JS-CSS.json index 04cd8f4fa..71168498e 100644 --- a/public/r/LetterGlitch-JS-CSS.json +++ b/public/r/LetterGlitch-JS-CSS.json @@ -8,7 +8,7 @@ { "type": "registry:component", "path": "LetterGlitch/LetterGlitch.jsx", - "content": "import { useRef, useEffect } from 'react';\n\nconst LetterGlitch = ({\n glitchColors = ['#2b4539', '#61dca3', '#61b3dc'],\n className = '',\n glitchSpeed = 50,\n centerVignette = false,\n outerVignette = true,\n smooth = true,\n lightMode = false,\n backgroundColor,\n characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$&*()-_+=/[]{};:<>.,0123456789'\n}) => {\n const canvasRef = useRef(null);\n const animationRef = useRef(null);\n const letters = useRef([]);\n const grid = useRef({ columns: 0, rows: 0 });\n const context = useRef(null);\n const lastGlitchTime = useRef(Date.now());\n\n const lettersAndSymbols = Array.from(characters);\n\n const fontSize = 16;\n const charWidth = 10;\n const charHeight = 20;\n\n const getRandomChar = () => {\n return lettersAndSymbols[Math.floor(Math.random() * lettersAndSymbols.length)];\n };\n\n const getRandomColor = () => {\n return glitchColors[Math.floor(Math.random() * glitchColors.length)];\n };\n\n const hexToRgb = hex => {\n const shorthandRegex = /^#?([a-f\\d])([a-f\\d])([a-f\\d])$/i;\n hex = hex.replace(shorthandRegex, (m, r, g, b) => {\n return r + r + g + g + b + b;\n });\n\n const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex);\n return result\n ? {\n r: parseInt(result[1], 16),\n g: parseInt(result[2], 16),\n b: parseInt(result[3], 16)\n }\n : null;\n };\n\n const interpolateColor = (start, end, factor) => {\n const result = {\n r: Math.round(start.r + (end.r - start.r) * factor),\n g: Math.round(start.g + (end.g - start.g) * factor),\n b: Math.round(start.b + (end.b - start.b) * factor)\n };\n return `rgb(${result.r}, ${result.g}, ${result.b})`;\n };\n\n const calculateGrid = (width, height) => {\n const columns = Math.ceil(width / charWidth);\n const rows = Math.ceil(height / charHeight);\n return { columns, rows };\n };\n\n const initializeLetters = (columns, rows) => {\n grid.current = { columns, rows };\n const totalLetters = columns * rows;\n letters.current = Array.from({ length: totalLetters }, () => ({\n char: getRandomChar(),\n color: getRandomColor(),\n targetColor: getRandomColor(),\n colorProgress: 1\n }));\n };\n\n const resizeCanvas = () => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const parent = canvas.parentElement;\n if (!parent) return;\n\n const dpr = window.devicePixelRatio || 1;\n const rect = parent.getBoundingClientRect();\n\n canvas.width = rect.width * dpr;\n canvas.height = rect.height * dpr;\n\n canvas.style.width = `${rect.width}px`;\n canvas.style.height = `${rect.height}px`;\n\n if (context.current) {\n context.current.setTransform(dpr, 0, 0, dpr, 0, 0);\n }\n\n const { columns, rows } = calculateGrid(rect.width, rect.height);\n initializeLetters(columns, rows);\n\n drawLetters();\n };\n\n const drawLetters = () => {\n if (!context.current || letters.current.length === 0) return;\n const ctx = context.current;\n const { width, height } = canvasRef.current.getBoundingClientRect();\n ctx.clearRect(0, 0, width, height);\n ctx.font = `${fontSize}px monospace`;\n ctx.textBaseline = 'top';\n\n letters.current.forEach((letter, index) => {\n const x = (index % grid.current.columns) * charWidth;\n const y = Math.floor(index / grid.current.columns) * charHeight;\n ctx.fillStyle = letter.color;\n ctx.fillText(letter.char, x, y);\n });\n };\n\n const updateLetters = () => {\n if (!letters.current || letters.current.length === 0) return;\n\n const updateCount = Math.max(1, Math.floor(letters.current.length * 0.05));\n\n for (let i = 0; i < updateCount; i++) {\n const index = Math.floor(Math.random() * letters.current.length);\n if (!letters.current[index]) continue;\n\n letters.current[index].char = getRandomChar();\n letters.current[index].targetColor = getRandomColor();\n\n if (!smooth) {\n letters.current[index].color = letters.current[index].targetColor;\n letters.current[index].colorProgress = 1;\n } else {\n letters.current[index].colorProgress = 0;\n }\n }\n };\n\n const handleSmoothTransitions = () => {\n let needsRedraw = false;\n letters.current.forEach(letter => {\n if (letter.colorProgress < 1) {\n letter.colorProgress += 0.05;\n if (letter.colorProgress > 1) letter.colorProgress = 1;\n\n const startRgb = hexToRgb(letter.color);\n const endRgb = hexToRgb(letter.targetColor);\n if (startRgb && endRgb) {\n letter.color = interpolateColor(startRgb, endRgb, letter.colorProgress);\n needsRedraw = true;\n }\n }\n });\n\n if (needsRedraw) {\n drawLetters();\n }\n };\n\n const animate = () => {\n const now = Date.now();\n if (now - lastGlitchTime.current >= glitchSpeed) {\n updateLetters();\n drawLetters();\n lastGlitchTime.current = now;\n }\n\n if (smooth) {\n handleSmoothTransitions();\n }\n\n animationRef.current = requestAnimationFrame(animate);\n };\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n\n context.current = canvas.getContext('2d');\n resizeCanvas();\n animate();\n\n let resizeTimeout;\n\n const handleResize = () => {\n clearTimeout(resizeTimeout);\n resizeTimeout = setTimeout(() => {\n cancelAnimationFrame(animationRef.current);\n resizeCanvas();\n animate();\n }, 100);\n };\n\n window.addEventListener('resize', handleResize);\n\n return () => {\n cancelAnimationFrame(animationRef.current);\n window.removeEventListener('resize', handleResize);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [glitchSpeed, smooth]);\n\n const containerStyle = {\n position: 'relative',\n width: '100%',\n height: '100%',\n backgroundColor: backgroundColor || (lightMode ? '#ffffff' : '#000000'),\n overflow: 'hidden'\n };\n\n const canvasStyle = {\n display: 'block',\n width: '100%',\n height: '100%'\n };\n\n const outerVignetteStyle = {\n position: 'absolute',\n top: 0,\n left: 0,\n width: '100%',\n height: '100%',\n pointerEvents: 'none',\n background: lightMode\n ? 'radial-gradient(circle, rgba(255,255,255,0) 58%, rgba(255,255,255,0.96) 100%)'\n : 'radial-gradient(circle, rgba(0,0,0,0) 60%, rgba(0,0,0,1) 100%)'\n };\n\n const centerVignetteStyle = {\n position: 'absolute',\n top: 0,\n left: 0,\n width: '100%',\n height: '100%',\n pointerEvents: 'none',\n background: lightMode\n ? 'radial-gradient(circle, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0) 60%)'\n : 'radial-gradient(circle, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0) 60%)'\n };\n\n return (\n
\n \n {outerVignette &&
}\n {centerVignette &&
}\n
\n );\n};\n\nexport default LetterGlitch;\n" + "content": "import { useRef, useEffect } from 'react';\n\nconst FALLBACK_RGB = { r: 255, g: 255, b: 255 };\n\nconst LetterGlitch = ({\n glitchColors = ['#2b4539', '#61dca3', '#61b3dc'],\n className = '',\n glitchSpeed = 50,\n centerVignette = false,\n outerVignette = true,\n smooth = true,\n lightMode = false,\n backgroundColor,\n characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$&*()-_+=/[]{};:<>.,0123456789'\n}) => {\n const canvasRef = useRef(null);\n const animationRef = useRef(null);\n const letters = useRef([]);\n const grid = useRef({ columns: 0, rows: 0 });\n const context = useRef(null);\n const lastGlitchTime = useRef(Date.now());\n\n const lettersAndSymbols = Array.from(characters);\n\n const fontSize = 16;\n const charWidth = 10;\n const charHeight = 20;\n\n const getRandomChar = () => {\n return lettersAndSymbols[Math.floor(Math.random() * lettersAndSymbols.length)];\n };\n\n const getRandomColor = () => {\n return glitchColors[Math.floor(Math.random() * glitchColors.length)];\n };\n\n const hexToRgb = hex => {\n const shorthandRegex = /^#?([a-f\\d])([a-f\\d])([a-f\\d])$/i;\n hex = hex.replace(shorthandRegex, (m, r, g, b) => {\n return r + r + g + g + b + b;\n });\n\n const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex);\n return result\n ? {\n r: parseInt(result[1], 16),\n g: parseInt(result[2], 16),\n b: parseInt(result[3], 16)\n }\n : null;\n };\n\n // Interpolation happens in numbers, and the CSS string is only built at\n // paint time. Previously the formatted `rgb(...)` string was stored back\n // on the letter and fed to hexToRgb on the next frame, which returned null\n // and froze the transition after a single step.\n const mixRgb = (start, end, factor) => ({\n r: Math.round(start.r + (end.r - start.r) * factor),\n g: Math.round(start.g + (end.g - start.g) * factor),\n b: Math.round(start.b + (end.b - start.b) * factor)\n });\n\n const rgbToCss = ({ r, g, b }) => `rgb(${r}, ${g}, ${b})`;\n\n // An unparseable entry in glitchColors must not stall the animation.\n const getRandomRgb = () => hexToRgb(getRandomColor()) || FALLBACK_RGB;\n\n const calculateGrid = (width, height) => {\n const columns = Math.ceil(width / charWidth);\n const rows = Math.ceil(height / charHeight);\n return { columns, rows };\n };\n\n const initializeLetters = (columns, rows) => {\n grid.current = { columns, rows };\n const totalLetters = columns * rows;\n letters.current = Array.from({ length: totalLetters }, () => {\n const rgb = getRandomRgb();\n return {\n char: getRandomChar(),\n rgb,\n fromRgb: rgb,\n targetRgb: getRandomRgb(),\n colorProgress: 1\n };\n });\n };\n\n const resizeCanvas = () => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const parent = canvas.parentElement;\n if (!parent) return;\n\n const dpr = window.devicePixelRatio || 1;\n const rect = parent.getBoundingClientRect();\n\n canvas.width = rect.width * dpr;\n canvas.height = rect.height * dpr;\n\n canvas.style.width = `${rect.width}px`;\n canvas.style.height = `${rect.height}px`;\n\n if (context.current) {\n context.current.setTransform(dpr, 0, 0, dpr, 0, 0);\n }\n\n const { columns, rows } = calculateGrid(rect.width, rect.height);\n initializeLetters(columns, rows);\n\n drawLetters();\n };\n\n const drawLetters = () => {\n if (!context.current || letters.current.length === 0) return;\n const ctx = context.current;\n const { width, height } = canvasRef.current.getBoundingClientRect();\n ctx.clearRect(0, 0, width, height);\n ctx.font = `${fontSize}px monospace`;\n ctx.textBaseline = 'top';\n\n letters.current.forEach((letter, index) => {\n const x = (index % grid.current.columns) * charWidth;\n const y = Math.floor(index / grid.current.columns) * charHeight;\n ctx.fillStyle = rgbToCss(letter.rgb);\n ctx.fillText(letter.char, x, y);\n });\n };\n\n const updateLetters = () => {\n if (!letters.current || letters.current.length === 0) return;\n\n const updateCount = Math.max(1, Math.floor(letters.current.length * 0.05));\n\n for (let i = 0; i < updateCount; i++) {\n const index = Math.floor(Math.random() * letters.current.length);\n if (!letters.current[index]) continue;\n\n letters.current[index].char = getRandomChar();\n // A new transition starts from the colour currently on screen, so a\n // letter picked again mid-fade continues instead of jumping.\n letters.current[index].fromRgb = letters.current[index].rgb;\n letters.current[index].targetRgb = getRandomRgb();\n\n if (!smooth) {\n letters.current[index].rgb = letters.current[index].targetRgb;\n letters.current[index].colorProgress = 1;\n } else {\n letters.current[index].colorProgress = 0;\n }\n }\n };\n\n const handleSmoothTransitions = () => {\n let needsRedraw = false;\n letters.current.forEach(letter => {\n if (letter.colorProgress < 1) {\n letter.colorProgress += 0.05;\n if (letter.colorProgress > 1) letter.colorProgress = 1;\n\n letter.rgb = mixRgb(letter.fromRgb, letter.targetRgb, letter.colorProgress);\n needsRedraw = true;\n }\n });\n\n if (needsRedraw) {\n drawLetters();\n }\n };\n\n const animate = () => {\n const now = Date.now();\n if (now - lastGlitchTime.current >= glitchSpeed) {\n updateLetters();\n drawLetters();\n lastGlitchTime.current = now;\n }\n\n if (smooth) {\n handleSmoothTransitions();\n }\n\n animationRef.current = requestAnimationFrame(animate);\n };\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n\n context.current = canvas.getContext('2d');\n resizeCanvas();\n animate();\n\n let resizeTimeout;\n\n const handleResize = () => {\n clearTimeout(resizeTimeout);\n resizeTimeout = setTimeout(() => {\n cancelAnimationFrame(animationRef.current);\n resizeCanvas();\n animate();\n }, 100);\n };\n\n window.addEventListener('resize', handleResize);\n\n return () => {\n cancelAnimationFrame(animationRef.current);\n window.removeEventListener('resize', handleResize);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [glitchSpeed, smooth]);\n\n const containerStyle = {\n position: 'relative',\n width: '100%',\n height: '100%',\n backgroundColor: backgroundColor || (lightMode ? '#ffffff' : '#000000'),\n overflow: 'hidden'\n };\n\n const canvasStyle = {\n display: 'block',\n width: '100%',\n height: '100%'\n };\n\n const outerVignetteStyle = {\n position: 'absolute',\n top: 0,\n left: 0,\n width: '100%',\n height: '100%',\n pointerEvents: 'none',\n background: lightMode\n ? 'radial-gradient(circle, rgba(255,255,255,0) 58%, rgba(255,255,255,0.96) 100%)'\n : 'radial-gradient(circle, rgba(0,0,0,0) 60%, rgba(0,0,0,1) 100%)'\n };\n\n const centerVignetteStyle = {\n position: 'absolute',\n top: 0,\n left: 0,\n width: '100%',\n height: '100%',\n pointerEvents: 'none',\n background: lightMode\n ? 'radial-gradient(circle, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0) 60%)'\n : 'radial-gradient(circle, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0) 60%)'\n };\n\n return (\n
\n \n {outerVignette &&
}\n {centerVignette &&
}\n
\n );\n};\n\nexport default LetterGlitch;\n" } ], "registryDependencies": [], diff --git a/public/r/LetterGlitch-JS-TW.json b/public/r/LetterGlitch-JS-TW.json index e672d99ab..1e3c6c154 100644 --- a/public/r/LetterGlitch-JS-TW.json +++ b/public/r/LetterGlitch-JS-TW.json @@ -8,7 +8,7 @@ { "type": "registry:component", "path": "LetterGlitch/LetterGlitch.jsx", - "content": "import { useRef, useEffect } from 'react';\n\nconst LetterGlitch = ({\n glitchColors = ['#2b4539', '#61dca3', '#61b3dc'],\n glitchSpeed = 50,\n centerVignette = false,\n outerVignette = true,\n smooth = true,\n lightMode = false,\n backgroundColor,\n className = '',\n characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$&*()-_+=/[]{};:<>.,0123456789'\n}) => {\n const canvasRef = useRef(null);\n const animationRef = useRef(null);\n const letters = useRef([]);\n const grid = useRef({ columns: 0, rows: 0 });\n const context = useRef(null);\n const lastGlitchTime = useRef(Date.now());\n\n const lettersAndSymbols = Array.from(characters);\n\n const fontSize = 16;\n const charWidth = 10;\n const charHeight = 20;\n\n const getRandomChar = () => {\n return lettersAndSymbols[Math.floor(Math.random() * lettersAndSymbols.length)];\n };\n\n const getRandomColor = () => {\n return glitchColors[Math.floor(Math.random() * glitchColors.length)];\n };\n\n const hexToRgb = hex => {\n const shorthandRegex = /^#?([a-f\\d])([a-f\\d])([a-f\\d])$/i;\n hex = hex.replace(shorthandRegex, (m, r, g, b) => {\n return r + r + g + g + b + b;\n });\n\n const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex);\n return result\n ? {\n r: parseInt(result[1], 16),\n g: parseInt(result[2], 16),\n b: parseInt(result[3], 16)\n }\n : null;\n };\n\n const interpolateColor = (start, end, factor) => {\n const result = {\n r: Math.round(start.r + (end.r - start.r) * factor),\n g: Math.round(start.g + (end.g - start.g) * factor),\n b: Math.round(start.b + (end.b - start.b) * factor)\n };\n return `rgb(${result.r}, ${result.g}, ${result.b})`;\n };\n\n const calculateGrid = (width, height) => {\n const columns = Math.ceil(width / charWidth);\n const rows = Math.ceil(height / charHeight);\n return { columns, rows };\n };\n\n const initializeLetters = (columns, rows) => {\n grid.current = { columns, rows };\n const totalLetters = columns * rows;\n letters.current = Array.from({ length: totalLetters }, () => ({\n char: getRandomChar(),\n color: getRandomColor(),\n targetColor: getRandomColor(),\n colorProgress: 1\n }));\n };\n\n const resizeCanvas = () => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const parent = canvas.parentElement;\n if (!parent) return;\n\n const dpr = window.devicePixelRatio || 1;\n const rect = parent.getBoundingClientRect();\n\n canvas.width = rect.width * dpr;\n canvas.height = rect.height * dpr;\n\n canvas.style.width = `${rect.width}px`;\n canvas.style.height = `${rect.height}px`;\n\n if (context.current) {\n context.current.setTransform(dpr, 0, 0, dpr, 0, 0);\n }\n\n const { columns, rows } = calculateGrid(rect.width, rect.height);\n initializeLetters(columns, rows);\n\n drawLetters();\n };\n\n const drawLetters = () => {\n if (!context.current || letters.current.length === 0) return;\n const ctx = context.current;\n const { width, height } = canvasRef.current.getBoundingClientRect();\n ctx.clearRect(0, 0, width, height);\n ctx.font = `${fontSize}px monospace`;\n ctx.textBaseline = 'top';\n\n letters.current.forEach((letter, index) => {\n const x = (index % grid.current.columns) * charWidth;\n const y = Math.floor(index / grid.current.columns) * charHeight;\n ctx.fillStyle = letter.color;\n ctx.fillText(letter.char, x, y);\n });\n };\n\n const updateLetters = () => {\n if (!letters.current || letters.current.length === 0) return;\n\n const updateCount = Math.max(1, Math.floor(letters.current.length * 0.05));\n\n for (let i = 0; i < updateCount; i++) {\n const index = Math.floor(Math.random() * letters.current.length);\n if (!letters.current[index]) continue;\n\n letters.current[index].char = getRandomChar();\n letters.current[index].targetColor = getRandomColor();\n\n if (!smooth) {\n letters.current[index].color = letters.current[index].targetColor;\n letters.current[index].colorProgress = 1;\n } else {\n letters.current[index].colorProgress = 0;\n }\n }\n };\n\n const handleSmoothTransitions = () => {\n let needsRedraw = false;\n letters.current.forEach(letter => {\n if (letter.colorProgress < 1) {\n letter.colorProgress += 0.05;\n if (letter.colorProgress > 1) letter.colorProgress = 1;\n\n const startRgb = hexToRgb(letter.color);\n const endRgb = hexToRgb(letter.targetColor);\n if (startRgb && endRgb) {\n letter.color = interpolateColor(startRgb, endRgb, letter.colorProgress);\n needsRedraw = true;\n }\n }\n });\n\n if (needsRedraw) {\n drawLetters();\n }\n };\n\n const animate = () => {\n const now = Date.now();\n if (now - lastGlitchTime.current >= glitchSpeed) {\n updateLetters();\n drawLetters();\n lastGlitchTime.current = now;\n }\n\n if (smooth) {\n handleSmoothTransitions();\n }\n\n animationRef.current = requestAnimationFrame(animate);\n };\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n\n context.current = canvas.getContext('2d');\n resizeCanvas();\n animate();\n\n let resizeTimeout;\n\n const handleResize = () => {\n clearTimeout(resizeTimeout);\n resizeTimeout = setTimeout(() => {\n cancelAnimationFrame(animationRef.current);\n resizeCanvas();\n animate();\n }, 100);\n };\n\n window.addEventListener('resize', handleResize);\n\n return () => {\n cancelAnimationFrame(animationRef.current);\n window.removeEventListener('resize', handleResize);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [glitchSpeed, smooth]);\n\n return (\n \n \n {outerVignette && (\n \n )}\n {centerVignette && (\n \n )}\n \n );\n};\n\nexport default LetterGlitch;\n" + "content": "import { useRef, useEffect } from 'react';\n\nconst FALLBACK_RGB = { r: 255, g: 255, b: 255 };\n\nconst LetterGlitch = ({\n glitchColors = ['#2b4539', '#61dca3', '#61b3dc'],\n glitchSpeed = 50,\n centerVignette = false,\n outerVignette = true,\n smooth = true,\n lightMode = false,\n backgroundColor,\n className = '',\n characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$&*()-_+=/[]{};:<>.,0123456789'\n}) => {\n const canvasRef = useRef(null);\n const animationRef = useRef(null);\n const letters = useRef([]);\n const grid = useRef({ columns: 0, rows: 0 });\n const context = useRef(null);\n const lastGlitchTime = useRef(Date.now());\n\n const lettersAndSymbols = Array.from(characters);\n\n const fontSize = 16;\n const charWidth = 10;\n const charHeight = 20;\n\n const getRandomChar = () => {\n return lettersAndSymbols[Math.floor(Math.random() * lettersAndSymbols.length)];\n };\n\n const getRandomColor = () => {\n return glitchColors[Math.floor(Math.random() * glitchColors.length)];\n };\n\n const hexToRgb = hex => {\n const shorthandRegex = /^#?([a-f\\d])([a-f\\d])([a-f\\d])$/i;\n hex = hex.replace(shorthandRegex, (m, r, g, b) => {\n return r + r + g + g + b + b;\n });\n\n const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex);\n return result\n ? {\n r: parseInt(result[1], 16),\n g: parseInt(result[2], 16),\n b: parseInt(result[3], 16)\n }\n : null;\n };\n\n // Interpolation happens in numbers, and the CSS string is only built at\n // paint time. Previously the formatted `rgb(...)` string was stored back\n // on the letter and fed to hexToRgb on the next frame, which returned null\n // and froze the transition after a single step.\n const mixRgb = (start, end, factor) => ({\n r: Math.round(start.r + (end.r - start.r) * factor),\n g: Math.round(start.g + (end.g - start.g) * factor),\n b: Math.round(start.b + (end.b - start.b) * factor)\n });\n\n const rgbToCss = ({ r, g, b }) => `rgb(${r}, ${g}, ${b})`;\n\n // An unparseable entry in glitchColors must not stall the animation.\n const getRandomRgb = () => hexToRgb(getRandomColor()) || FALLBACK_RGB;\n\n const calculateGrid = (width, height) => {\n const columns = Math.ceil(width / charWidth);\n const rows = Math.ceil(height / charHeight);\n return { columns, rows };\n };\n\n const initializeLetters = (columns, rows) => {\n grid.current = { columns, rows };\n const totalLetters = columns * rows;\n letters.current = Array.from({ length: totalLetters }, () => {\n const rgb = getRandomRgb();\n return {\n char: getRandomChar(),\n rgb,\n fromRgb: rgb,\n targetRgb: getRandomRgb(),\n colorProgress: 1\n };\n });\n };\n\n const resizeCanvas = () => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const parent = canvas.parentElement;\n if (!parent) return;\n\n const dpr = window.devicePixelRatio || 1;\n const rect = parent.getBoundingClientRect();\n\n canvas.width = rect.width * dpr;\n canvas.height = rect.height * dpr;\n\n canvas.style.width = `${rect.width}px`;\n canvas.style.height = `${rect.height}px`;\n\n if (context.current) {\n context.current.setTransform(dpr, 0, 0, dpr, 0, 0);\n }\n\n const { columns, rows } = calculateGrid(rect.width, rect.height);\n initializeLetters(columns, rows);\n\n drawLetters();\n };\n\n const drawLetters = () => {\n if (!context.current || letters.current.length === 0) return;\n const ctx = context.current;\n const { width, height } = canvasRef.current.getBoundingClientRect();\n ctx.clearRect(0, 0, width, height);\n ctx.font = `${fontSize}px monospace`;\n ctx.textBaseline = 'top';\n\n letters.current.forEach((letter, index) => {\n const x = (index % grid.current.columns) * charWidth;\n const y = Math.floor(index / grid.current.columns) * charHeight;\n ctx.fillStyle = rgbToCss(letter.rgb);\n ctx.fillText(letter.char, x, y);\n });\n };\n\n const updateLetters = () => {\n if (!letters.current || letters.current.length === 0) return;\n\n const updateCount = Math.max(1, Math.floor(letters.current.length * 0.05));\n\n for (let i = 0; i < updateCount; i++) {\n const index = Math.floor(Math.random() * letters.current.length);\n if (!letters.current[index]) continue;\n\n letters.current[index].char = getRandomChar();\n // A new transition starts from the colour currently on screen, so a\n // letter picked again mid-fade continues instead of jumping.\n letters.current[index].fromRgb = letters.current[index].rgb;\n letters.current[index].targetRgb = getRandomRgb();\n\n if (!smooth) {\n letters.current[index].rgb = letters.current[index].targetRgb;\n letters.current[index].colorProgress = 1;\n } else {\n letters.current[index].colorProgress = 0;\n }\n }\n };\n\n const handleSmoothTransitions = () => {\n let needsRedraw = false;\n letters.current.forEach(letter => {\n if (letter.colorProgress < 1) {\n letter.colorProgress += 0.05;\n if (letter.colorProgress > 1) letter.colorProgress = 1;\n\n letter.rgb = mixRgb(letter.fromRgb, letter.targetRgb, letter.colorProgress);\n needsRedraw = true;\n }\n });\n\n if (needsRedraw) {\n drawLetters();\n }\n };\n\n const animate = () => {\n const now = Date.now();\n if (now - lastGlitchTime.current >= glitchSpeed) {\n updateLetters();\n drawLetters();\n lastGlitchTime.current = now;\n }\n\n if (smooth) {\n handleSmoothTransitions();\n }\n\n animationRef.current = requestAnimationFrame(animate);\n };\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n\n context.current = canvas.getContext('2d');\n resizeCanvas();\n animate();\n\n let resizeTimeout;\n\n const handleResize = () => {\n clearTimeout(resizeTimeout);\n resizeTimeout = setTimeout(() => {\n cancelAnimationFrame(animationRef.current);\n resizeCanvas();\n animate();\n }, 100);\n };\n\n window.addEventListener('resize', handleResize);\n\n return () => {\n cancelAnimationFrame(animationRef.current);\n window.removeEventListener('resize', handleResize);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [glitchSpeed, smooth]);\n\n return (\n \n \n {outerVignette && (\n \n )}\n {centerVignette && (\n \n )}\n \n );\n};\n\nexport default LetterGlitch;\n" } ], "registryDependencies": [], diff --git a/public/r/LetterGlitch-TS-CSS.json b/public/r/LetterGlitch-TS-CSS.json index bd2452205..b1e8956ee 100644 --- a/public/r/LetterGlitch-TS-CSS.json +++ b/public/r/LetterGlitch-TS-CSS.json @@ -8,7 +8,7 @@ { "type": "registry:component", "path": "LetterGlitch/LetterGlitch.tsx", - "content": "import { useRef, useEffect } from 'react';\n\nconst LetterGlitch = ({\n glitchColors = ['#2b4539', '#61dca3', '#61b3dc'],\n glitchSpeed = 50,\n centerVignette = false,\n outerVignette = true,\n smooth = true,\n lightMode = false,\n backgroundColor,\n className = '',\n characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$&*()-_+=/[]{};:<>.,0123456789'\n}: {\n glitchColors: string[];\n glitchSpeed: number;\n centerVignette: boolean;\n outerVignette: boolean;\n smooth: boolean;\n lightMode?: boolean;\n backgroundColor?: string;\n className?: string;\n characters: string;\n}) => {\n const canvasRef = useRef(null);\n const animationRef = useRef(null);\n const letters = useRef<\n {\n char: string;\n color: string;\n targetColor: string;\n colorProgress: number;\n }[]\n >([]);\n const grid = useRef({ columns: 0, rows: 0 });\n const context = useRef(null);\n const lastGlitchTime = useRef(Date.now());\n\n const lettersAndSymbols = Array.from(characters);\n\n const fontSize = 16;\n const charWidth = 10;\n const charHeight = 20;\n\n const getRandomChar = () => {\n return lettersAndSymbols[Math.floor(Math.random() * lettersAndSymbols.length)];\n };\n\n const getRandomColor = () => {\n return glitchColors[Math.floor(Math.random() * glitchColors.length)];\n };\n\n const hexToRgb = (hex: string) => {\n const shorthandRegex = /^#?([a-f\\d])([a-f\\d])([a-f\\d])$/i;\n hex = hex.replace(shorthandRegex, (_m, r, g, b) => {\n return r + r + g + g + b + b;\n });\n\n const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex);\n return result\n ? {\n r: parseInt(result[1], 16),\n g: parseInt(result[2], 16),\n b: parseInt(result[3], 16)\n }\n : null;\n };\n\n const interpolateColor = (\n start: { r: number; g: number; b: number },\n end: { r: number; g: number; b: number },\n factor: number\n ) => {\n const result = {\n r: Math.round(start.r + (end.r - start.r) * factor),\n g: Math.round(start.g + (end.g - start.g) * factor),\n b: Math.round(start.b + (end.b - start.b) * factor)\n };\n return `rgb(${result.r}, ${result.g}, ${result.b})`;\n };\n\n const calculateGrid = (width: number, height: number) => {\n const columns = Math.ceil(width / charWidth);\n const rows = Math.ceil(height / charHeight);\n return { columns, rows };\n };\n\n const initializeLetters = (columns: number, rows: number) => {\n grid.current = { columns, rows };\n const totalLetters = columns * rows;\n letters.current = Array.from({ length: totalLetters }, () => ({\n char: getRandomChar(),\n color: getRandomColor(),\n targetColor: getRandomColor(),\n colorProgress: 1\n }));\n };\n\n const resizeCanvas = () => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const parent = canvas.parentElement;\n if (!parent) return;\n\n const dpr = window.devicePixelRatio || 1;\n const rect = parent.getBoundingClientRect();\n\n canvas.width = rect.width * dpr;\n canvas.height = rect.height * dpr;\n\n canvas.style.width = `${rect.width}px`;\n canvas.style.height = `${rect.height}px`;\n\n if (context.current) {\n context.current.setTransform(dpr, 0, 0, dpr, 0, 0);\n }\n\n const { columns, rows } = calculateGrid(rect.width, rect.height);\n initializeLetters(columns, rows);\n drawLetters();\n };\n\n const drawLetters = () => {\n if (!context.current || letters.current.length === 0) return;\n const ctx = context.current;\n const { width, height } = canvasRef.current!.getBoundingClientRect();\n ctx.clearRect(0, 0, width, height);\n ctx.font = `${fontSize}px monospace`;\n ctx.textBaseline = 'top';\n\n letters.current.forEach((letter, index) => {\n const x = (index % grid.current.columns) * charWidth;\n const y = Math.floor(index / grid.current.columns) * charHeight;\n ctx.fillStyle = letter.color;\n ctx.fillText(letter.char, x, y);\n });\n };\n\n const updateLetters = () => {\n if (!letters.current || letters.current.length === 0) return;\n\n const updateCount = Math.max(1, Math.floor(letters.current.length * 0.05));\n\n for (let i = 0; i < updateCount; i++) {\n const index = Math.floor(Math.random() * letters.current.length);\n if (!letters.current[index]) continue;\n\n letters.current[index].char = getRandomChar();\n letters.current[index].targetColor = getRandomColor();\n\n if (!smooth) {\n letters.current[index].color = letters.current[index].targetColor;\n letters.current[index].colorProgress = 1;\n } else {\n letters.current[index].colorProgress = 0;\n }\n }\n };\n\n const handleSmoothTransitions = () => {\n let needsRedraw = false;\n letters.current.forEach(letter => {\n if (letter.colorProgress < 1) {\n letter.colorProgress += 0.05;\n if (letter.colorProgress > 1) letter.colorProgress = 1;\n\n const startRgb = hexToRgb(letter.color);\n const endRgb = hexToRgb(letter.targetColor);\n if (startRgb && endRgb) {\n letter.color = interpolateColor(startRgb, endRgb, letter.colorProgress);\n needsRedraw = true;\n }\n }\n });\n\n if (needsRedraw) {\n drawLetters();\n }\n };\n\n const animate = () => {\n const now = Date.now();\n if (now - lastGlitchTime.current >= glitchSpeed) {\n updateLetters();\n drawLetters();\n lastGlitchTime.current = now;\n }\n\n if (smooth) {\n handleSmoothTransitions();\n }\n\n animationRef.current = requestAnimationFrame(animate);\n };\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n\n context.current = canvas.getContext('2d');\n resizeCanvas();\n animate();\n\n let resizeTimeout: ReturnType;\n\n const handleResize = () => {\n clearTimeout(resizeTimeout);\n resizeTimeout = setTimeout(() => {\n cancelAnimationFrame(animationRef.current as number);\n resizeCanvas();\n animate();\n }, 100);\n };\n\n window.addEventListener('resize', handleResize);\n\n return () => {\n cancelAnimationFrame(animationRef.current!);\n window.removeEventListener('resize', handleResize);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [glitchSpeed, smooth]);\n\n const containerStyle = {\n position: 'relative',\n width: '100%',\n height: '100%',\n backgroundColor: backgroundColor || (lightMode ? '#ffffff' : '#000000'),\n overflow: 'hidden'\n };\n\n const canvasStyle = {\n display: 'block',\n width: '100%',\n height: '100%'\n };\n\n const outerVignetteStyle = {\n position: 'absolute',\n top: 0,\n left: 0,\n width: '100%',\n height: '100%',\n pointerEvents: 'none',\n background: lightMode\n ? 'radial-gradient(circle, rgba(255,255,255,0) 58%, rgba(255,255,255,0.96) 100%)'\n : 'radial-gradient(circle, rgba(0,0,0,0) 60%, rgba(0,0,0,1) 100%)'\n };\n\n const centerVignetteStyle = {\n position: 'absolute',\n top: 0,\n left: 0,\n width: '100%',\n height: '100%',\n pointerEvents: 'none',\n background: lightMode\n ? 'radial-gradient(circle, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0) 60%)'\n : 'radial-gradient(circle, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0) 60%)'\n };\n\n return (\n
\n \n {outerVignette &&
}\n {centerVignette &&
}\n
\n );\n};\n\nexport default LetterGlitch;\n" + "content": "import { useRef, useEffect } from 'react';\n\ninterface Rgb {\n r: number;\n g: number;\n b: number;\n}\n\nconst FALLBACK_RGB: Rgb = { r: 255, g: 255, b: 255 };\n\nconst LetterGlitch = ({\n glitchColors = ['#2b4539', '#61dca3', '#61b3dc'],\n glitchSpeed = 50,\n centerVignette = false,\n outerVignette = true,\n smooth = true,\n lightMode = false,\n backgroundColor,\n className = '',\n characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$&*()-_+=/[]{};:<>.,0123456789'\n}: {\n glitchColors: string[];\n glitchSpeed: number;\n centerVignette: boolean;\n outerVignette: boolean;\n smooth: boolean;\n lightMode?: boolean;\n backgroundColor?: string;\n className?: string;\n characters: string;\n}) => {\n const canvasRef = useRef(null);\n const animationRef = useRef(null);\n const letters = useRef<\n {\n char: string;\n rgb: Rgb;\n fromRgb: Rgb;\n targetRgb: Rgb;\n colorProgress: number;\n }[]\n >([]);\n const grid = useRef({ columns: 0, rows: 0 });\n const context = useRef(null);\n const lastGlitchTime = useRef(Date.now());\n\n const lettersAndSymbols = Array.from(characters);\n\n const fontSize = 16;\n const charWidth = 10;\n const charHeight = 20;\n\n const getRandomChar = () => {\n return lettersAndSymbols[Math.floor(Math.random() * lettersAndSymbols.length)];\n };\n\n const getRandomColor = () => {\n return glitchColors[Math.floor(Math.random() * glitchColors.length)];\n };\n\n const hexToRgb = (hex: string) => {\n const shorthandRegex = /^#?([a-f\\d])([a-f\\d])([a-f\\d])$/i;\n hex = hex.replace(shorthandRegex, (_m, r, g, b) => {\n return r + r + g + g + b + b;\n });\n\n const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex);\n return result\n ? {\n r: parseInt(result[1], 16),\n g: parseInt(result[2], 16),\n b: parseInt(result[3], 16)\n }\n : null;\n };\n\n // Interpolation happens in numbers, and the CSS string is only built at\n // paint time. Previously the formatted `rgb(...)` string was stored back\n // on the letter and fed to hexToRgb on the next frame, which returned null\n // and froze the transition after a single step.\n const mixRgb = (start: Rgb, end: Rgb, factor: number): Rgb => ({\n r: Math.round(start.r + (end.r - start.r) * factor),\n g: Math.round(start.g + (end.g - start.g) * factor),\n b: Math.round(start.b + (end.b - start.b) * factor)\n });\n\n const rgbToCss = ({ r, g, b }: Rgb) => `rgb(${r}, ${g}, ${b})`;\n\n // An unparseable entry in glitchColors must not stall the animation.\n const getRandomRgb = (): Rgb => hexToRgb(getRandomColor()) || FALLBACK_RGB;\n\n const calculateGrid = (width: number, height: number) => {\n const columns = Math.ceil(width / charWidth);\n const rows = Math.ceil(height / charHeight);\n return { columns, rows };\n };\n\n const initializeLetters = (columns: number, rows: number) => {\n grid.current = { columns, rows };\n const totalLetters = columns * rows;\n letters.current = Array.from({ length: totalLetters }, () => {\n const rgb = getRandomRgb();\n return {\n char: getRandomChar(),\n rgb,\n fromRgb: rgb,\n targetRgb: getRandomRgb(),\n colorProgress: 1\n };\n });\n };\n\n const resizeCanvas = () => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const parent = canvas.parentElement;\n if (!parent) return;\n\n const dpr = window.devicePixelRatio || 1;\n const rect = parent.getBoundingClientRect();\n\n canvas.width = rect.width * dpr;\n canvas.height = rect.height * dpr;\n\n canvas.style.width = `${rect.width}px`;\n canvas.style.height = `${rect.height}px`;\n\n if (context.current) {\n context.current.setTransform(dpr, 0, 0, dpr, 0, 0);\n }\n\n const { columns, rows } = calculateGrid(rect.width, rect.height);\n initializeLetters(columns, rows);\n drawLetters();\n };\n\n const drawLetters = () => {\n if (!context.current || letters.current.length === 0) return;\n const ctx = context.current;\n const { width, height } = canvasRef.current!.getBoundingClientRect();\n ctx.clearRect(0, 0, width, height);\n ctx.font = `${fontSize}px monospace`;\n ctx.textBaseline = 'top';\n\n letters.current.forEach((letter, index) => {\n const x = (index % grid.current.columns) * charWidth;\n const y = Math.floor(index / grid.current.columns) * charHeight;\n ctx.fillStyle = rgbToCss(letter.rgb);\n ctx.fillText(letter.char, x, y);\n });\n };\n\n const updateLetters = () => {\n if (!letters.current || letters.current.length === 0) return;\n\n const updateCount = Math.max(1, Math.floor(letters.current.length * 0.05));\n\n for (let i = 0; i < updateCount; i++) {\n const index = Math.floor(Math.random() * letters.current.length);\n if (!letters.current[index]) continue;\n\n letters.current[index].char = getRandomChar();\n // A new transition starts from the colour currently on screen, so a\n // letter picked again mid-fade continues instead of jumping.\n letters.current[index].fromRgb = letters.current[index].rgb;\n letters.current[index].targetRgb = getRandomRgb();\n\n if (!smooth) {\n letters.current[index].rgb = letters.current[index].targetRgb;\n letters.current[index].colorProgress = 1;\n } else {\n letters.current[index].colorProgress = 0;\n }\n }\n };\n\n const handleSmoothTransitions = () => {\n let needsRedraw = false;\n letters.current.forEach(letter => {\n if (letter.colorProgress < 1) {\n letter.colorProgress += 0.05;\n if (letter.colorProgress > 1) letter.colorProgress = 1;\n\n letter.rgb = mixRgb(letter.fromRgb, letter.targetRgb, letter.colorProgress);\n needsRedraw = true;\n }\n });\n\n if (needsRedraw) {\n drawLetters();\n }\n };\n\n const animate = () => {\n const now = Date.now();\n if (now - lastGlitchTime.current >= glitchSpeed) {\n updateLetters();\n drawLetters();\n lastGlitchTime.current = now;\n }\n\n if (smooth) {\n handleSmoothTransitions();\n }\n\n animationRef.current = requestAnimationFrame(animate);\n };\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n\n context.current = canvas.getContext('2d');\n resizeCanvas();\n animate();\n\n let resizeTimeout: ReturnType;\n\n const handleResize = () => {\n clearTimeout(resizeTimeout);\n resizeTimeout = setTimeout(() => {\n cancelAnimationFrame(animationRef.current as number);\n resizeCanvas();\n animate();\n }, 100);\n };\n\n window.addEventListener('resize', handleResize);\n\n return () => {\n cancelAnimationFrame(animationRef.current!);\n window.removeEventListener('resize', handleResize);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [glitchSpeed, smooth]);\n\n const containerStyle = {\n position: 'relative',\n width: '100%',\n height: '100%',\n backgroundColor: backgroundColor || (lightMode ? '#ffffff' : '#000000'),\n overflow: 'hidden'\n };\n\n const canvasStyle = {\n display: 'block',\n width: '100%',\n height: '100%'\n };\n\n const outerVignetteStyle = {\n position: 'absolute',\n top: 0,\n left: 0,\n width: '100%',\n height: '100%',\n pointerEvents: 'none',\n background: lightMode\n ? 'radial-gradient(circle, rgba(255,255,255,0) 58%, rgba(255,255,255,0.96) 100%)'\n : 'radial-gradient(circle, rgba(0,0,0,0) 60%, rgba(0,0,0,1) 100%)'\n };\n\n const centerVignetteStyle = {\n position: 'absolute',\n top: 0,\n left: 0,\n width: '100%',\n height: '100%',\n pointerEvents: 'none',\n background: lightMode\n ? 'radial-gradient(circle, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0) 60%)'\n : 'radial-gradient(circle, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0) 60%)'\n };\n\n return (\n
\n \n {outerVignette &&
}\n {centerVignette &&
}\n
\n );\n};\n\nexport default LetterGlitch;\n" } ], "registryDependencies": [], diff --git a/public/r/LetterGlitch-TS-TW.json b/public/r/LetterGlitch-TS-TW.json index d620871a3..dfcee2e34 100644 --- a/public/r/LetterGlitch-TS-TW.json +++ b/public/r/LetterGlitch-TS-TW.json @@ -8,7 +8,7 @@ { "type": "registry:component", "path": "LetterGlitch/LetterGlitch.tsx", - "content": "import { useRef, useEffect } from 'react';\n\nconst LetterGlitch = ({\n glitchColors = ['#2b4539', '#61dca3', '#61b3dc'],\n glitchSpeed = 50,\n centerVignette = false,\n outerVignette = true,\n smooth = true,\n lightMode = false,\n backgroundColor,\n className = '',\n characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$&*()-_+=/[]{};:<>.,0123456789'\n}: {\n glitchColors: string[];\n glitchSpeed: number;\n centerVignette: boolean;\n outerVignette: boolean;\n smooth: boolean;\n lightMode?: boolean;\n backgroundColor?: string;\n className?: string;\n characters: string;\n}) => {\n const canvasRef = useRef(null);\n const animationRef = useRef(null);\n const letters = useRef<\n {\n char: string;\n color: string;\n targetColor: string;\n colorProgress: number;\n }[]\n >([]);\n const grid = useRef({ columns: 0, rows: 0 });\n const context = useRef(null);\n const lastGlitchTime = useRef(Date.now());\n\n const lettersAndSymbols = Array.from(characters);\n\n const fontSize = 16;\n const charWidth = 10;\n const charHeight = 20;\n\n const getRandomChar = () => {\n return lettersAndSymbols[Math.floor(Math.random() * lettersAndSymbols.length)];\n };\n\n const getRandomColor = () => {\n return glitchColors[Math.floor(Math.random() * glitchColors.length)];\n };\n\n const hexToRgb = (hex: string) => {\n const shorthandRegex = /^#?([a-f\\d])([a-f\\d])([a-f\\d])$/i;\n hex = hex.replace(shorthandRegex, (_m, r, g, b) => {\n return r + r + g + g + b + b;\n });\n\n const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex);\n return result\n ? {\n r: parseInt(result[1], 16),\n g: parseInt(result[2], 16),\n b: parseInt(result[3], 16)\n }\n : null;\n };\n\n const interpolateColor = (\n start: { r: number; g: number; b: number },\n end: { r: number; g: number; b: number },\n factor: number\n ) => {\n const result = {\n r: Math.round(start.r + (end.r - start.r) * factor),\n g: Math.round(start.g + (end.g - start.g) * factor),\n b: Math.round(start.b + (end.b - start.b) * factor)\n };\n return `rgb(${result.r}, ${result.g}, ${result.b})`;\n };\n\n const calculateGrid = (width: number, height: number) => {\n const columns = Math.ceil(width / charWidth);\n const rows = Math.ceil(height / charHeight);\n return { columns, rows };\n };\n\n const initializeLetters = (columns: number, rows: number) => {\n grid.current = { columns, rows };\n const totalLetters = columns * rows;\n letters.current = Array.from({ length: totalLetters }, () => ({\n char: getRandomChar(),\n color: getRandomColor(),\n targetColor: getRandomColor(),\n colorProgress: 1\n }));\n };\n\n const resizeCanvas = () => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const parent = canvas.parentElement;\n if (!parent) return;\n\n const dpr = window.devicePixelRatio || 1;\n const rect = parent.getBoundingClientRect();\n\n canvas.width = rect.width * dpr;\n canvas.height = rect.height * dpr;\n\n canvas.style.width = `${rect.width}px`;\n canvas.style.height = `${rect.height}px`;\n\n if (context.current) {\n context.current.setTransform(dpr, 0, 0, dpr, 0, 0);\n }\n\n const { columns, rows } = calculateGrid(rect.width, rect.height);\n initializeLetters(columns, rows);\n drawLetters();\n };\n\n const drawLetters = () => {\n if (!context.current || letters.current.length === 0) return;\n const ctx = context.current;\n const { width, height } = canvasRef.current!.getBoundingClientRect();\n ctx.clearRect(0, 0, width, height);\n ctx.font = `${fontSize}px monospace`;\n ctx.textBaseline = 'top';\n\n letters.current.forEach((letter, index) => {\n const x = (index % grid.current.columns) * charWidth;\n const y = Math.floor(index / grid.current.columns) * charHeight;\n ctx.fillStyle = letter.color;\n ctx.fillText(letter.char, x, y);\n });\n };\n\n const updateLetters = () => {\n if (!letters.current || letters.current.length === 0) return;\n\n const updateCount = Math.max(1, Math.floor(letters.current.length * 0.05));\n\n for (let i = 0; i < updateCount; i++) {\n const index = Math.floor(Math.random() * letters.current.length);\n if (!letters.current[index]) continue;\n\n letters.current[index].char = getRandomChar();\n letters.current[index].targetColor = getRandomColor();\n\n if (!smooth) {\n letters.current[index].color = letters.current[index].targetColor;\n letters.current[index].colorProgress = 1;\n } else {\n letters.current[index].colorProgress = 0;\n }\n }\n };\n\n const handleSmoothTransitions = () => {\n let needsRedraw = false;\n letters.current.forEach(letter => {\n if (letter.colorProgress < 1) {\n letter.colorProgress += 0.05;\n if (letter.colorProgress > 1) letter.colorProgress = 1;\n\n const startRgb = hexToRgb(letter.color);\n const endRgb = hexToRgb(letter.targetColor);\n if (startRgb && endRgb) {\n letter.color = interpolateColor(startRgb, endRgb, letter.colorProgress);\n needsRedraw = true;\n }\n }\n });\n\n if (needsRedraw) {\n drawLetters();\n }\n };\n\n const animate = () => {\n const now = Date.now();\n if (now - lastGlitchTime.current >= glitchSpeed) {\n updateLetters();\n drawLetters();\n lastGlitchTime.current = now;\n }\n\n if (smooth) {\n handleSmoothTransitions();\n }\n\n animationRef.current = requestAnimationFrame(animate);\n };\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n\n context.current = canvas.getContext('2d');\n resizeCanvas();\n animate();\n\n let resizeTimeout: ReturnType;\n\n const handleResize = () => {\n clearTimeout(resizeTimeout);\n resizeTimeout = setTimeout(() => {\n cancelAnimationFrame(animationRef.current as number);\n resizeCanvas();\n animate();\n }, 100);\n };\n\n window.addEventListener('resize', handleResize);\n\n return () => {\n cancelAnimationFrame(animationRef.current!);\n window.removeEventListener('resize', handleResize);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [glitchSpeed, smooth]);\n\n return (\n \n \n {outerVignette && (\n \n )}\n {centerVignette && (\n \n )}\n \n );\n};\n\nexport default LetterGlitch;\n" + "content": "import { useRef, useEffect } from 'react';\n\ninterface Rgb {\n r: number;\n g: number;\n b: number;\n}\n\nconst FALLBACK_RGB: Rgb = { r: 255, g: 255, b: 255 };\n\nconst LetterGlitch = ({\n glitchColors = ['#2b4539', '#61dca3', '#61b3dc'],\n glitchSpeed = 50,\n centerVignette = false,\n outerVignette = true,\n smooth = true,\n lightMode = false,\n backgroundColor,\n className = '',\n characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$&*()-_+=/[]{};:<>.,0123456789'\n}: {\n glitchColors: string[];\n glitchSpeed: number;\n centerVignette: boolean;\n outerVignette: boolean;\n smooth: boolean;\n lightMode?: boolean;\n backgroundColor?: string;\n className?: string;\n characters: string;\n}) => {\n const canvasRef = useRef(null);\n const animationRef = useRef(null);\n const letters = useRef<\n {\n char: string;\n rgb: Rgb;\n fromRgb: Rgb;\n targetRgb: Rgb;\n colorProgress: number;\n }[]\n >([]);\n const grid = useRef({ columns: 0, rows: 0 });\n const context = useRef(null);\n const lastGlitchTime = useRef(Date.now());\n\n const lettersAndSymbols = Array.from(characters);\n\n const fontSize = 16;\n const charWidth = 10;\n const charHeight = 20;\n\n const getRandomChar = () => {\n return lettersAndSymbols[Math.floor(Math.random() * lettersAndSymbols.length)];\n };\n\n const getRandomColor = () => {\n return glitchColors[Math.floor(Math.random() * glitchColors.length)];\n };\n\n const hexToRgb = (hex: string) => {\n const shorthandRegex = /^#?([a-f\\d])([a-f\\d])([a-f\\d])$/i;\n hex = hex.replace(shorthandRegex, (_m, r, g, b) => {\n return r + r + g + g + b + b;\n });\n\n const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex);\n return result\n ? {\n r: parseInt(result[1], 16),\n g: parseInt(result[2], 16),\n b: parseInt(result[3], 16)\n }\n : null;\n };\n\n // Interpolation happens in numbers, and the CSS string is only built at\n // paint time. Previously the formatted `rgb(...)` string was stored back\n // on the letter and fed to hexToRgb on the next frame, which returned null\n // and froze the transition after a single step.\n const mixRgb = (start: Rgb, end: Rgb, factor: number): Rgb => ({\n r: Math.round(start.r + (end.r - start.r) * factor),\n g: Math.round(start.g + (end.g - start.g) * factor),\n b: Math.round(start.b + (end.b - start.b) * factor)\n });\n\n const rgbToCss = ({ r, g, b }: Rgb) => `rgb(${r}, ${g}, ${b})`;\n\n // An unparseable entry in glitchColors must not stall the animation.\n const getRandomRgb = (): Rgb => hexToRgb(getRandomColor()) || FALLBACK_RGB;\n\n const calculateGrid = (width: number, height: number) => {\n const columns = Math.ceil(width / charWidth);\n const rows = Math.ceil(height / charHeight);\n return { columns, rows };\n };\n\n const initializeLetters = (columns: number, rows: number) => {\n grid.current = { columns, rows };\n const totalLetters = columns * rows;\n letters.current = Array.from({ length: totalLetters }, () => {\n const rgb = getRandomRgb();\n return {\n char: getRandomChar(),\n rgb,\n fromRgb: rgb,\n targetRgb: getRandomRgb(),\n colorProgress: 1\n };\n });\n };\n\n const resizeCanvas = () => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const parent = canvas.parentElement;\n if (!parent) return;\n\n const dpr = window.devicePixelRatio || 1;\n const rect = parent.getBoundingClientRect();\n\n canvas.width = rect.width * dpr;\n canvas.height = rect.height * dpr;\n\n canvas.style.width = `${rect.width}px`;\n canvas.style.height = `${rect.height}px`;\n\n if (context.current) {\n context.current.setTransform(dpr, 0, 0, dpr, 0, 0);\n }\n\n const { columns, rows } = calculateGrid(rect.width, rect.height);\n initializeLetters(columns, rows);\n drawLetters();\n };\n\n const drawLetters = () => {\n if (!context.current || letters.current.length === 0) return;\n const ctx = context.current;\n const { width, height } = canvasRef.current!.getBoundingClientRect();\n ctx.clearRect(0, 0, width, height);\n ctx.font = `${fontSize}px monospace`;\n ctx.textBaseline = 'top';\n\n letters.current.forEach((letter, index) => {\n const x = (index % grid.current.columns) * charWidth;\n const y = Math.floor(index / grid.current.columns) * charHeight;\n ctx.fillStyle = rgbToCss(letter.rgb);\n ctx.fillText(letter.char, x, y);\n });\n };\n\n const updateLetters = () => {\n if (!letters.current || letters.current.length === 0) return;\n\n const updateCount = Math.max(1, Math.floor(letters.current.length * 0.05));\n\n for (let i = 0; i < updateCount; i++) {\n const index = Math.floor(Math.random() * letters.current.length);\n if (!letters.current[index]) continue;\n\n letters.current[index].char = getRandomChar();\n // A new transition starts from the colour currently on screen, so a\n // letter picked again mid-fade continues instead of jumping.\n letters.current[index].fromRgb = letters.current[index].rgb;\n letters.current[index].targetRgb = getRandomRgb();\n\n if (!smooth) {\n letters.current[index].rgb = letters.current[index].targetRgb;\n letters.current[index].colorProgress = 1;\n } else {\n letters.current[index].colorProgress = 0;\n }\n }\n };\n\n const handleSmoothTransitions = () => {\n let needsRedraw = false;\n letters.current.forEach(letter => {\n if (letter.colorProgress < 1) {\n letter.colorProgress += 0.05;\n if (letter.colorProgress > 1) letter.colorProgress = 1;\n\n letter.rgb = mixRgb(letter.fromRgb, letter.targetRgb, letter.colorProgress);\n needsRedraw = true;\n }\n });\n\n if (needsRedraw) {\n drawLetters();\n }\n };\n\n const animate = () => {\n const now = Date.now();\n if (now - lastGlitchTime.current >= glitchSpeed) {\n updateLetters();\n drawLetters();\n lastGlitchTime.current = now;\n }\n\n if (smooth) {\n handleSmoothTransitions();\n }\n\n animationRef.current = requestAnimationFrame(animate);\n };\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n\n context.current = canvas.getContext('2d');\n resizeCanvas();\n animate();\n\n let resizeTimeout: ReturnType;\n\n const handleResize = () => {\n clearTimeout(resizeTimeout);\n resizeTimeout = setTimeout(() => {\n cancelAnimationFrame(animationRef.current as number);\n resizeCanvas();\n animate();\n }, 100);\n };\n\n window.addEventListener('resize', handleResize);\n\n return () => {\n cancelAnimationFrame(animationRef.current!);\n window.removeEventListener('resize', handleResize);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [glitchSpeed, smooth]);\n\n return (\n \n \n {outerVignette && (\n \n )}\n {centerVignette && (\n \n )}\n \n );\n};\n\nexport default LetterGlitch;\n" } ], "registryDependencies": [], diff --git a/src/content/Backgrounds/LetterGlitch/LetterGlitch.jsx b/src/content/Backgrounds/LetterGlitch/LetterGlitch.jsx index 64c2f8d35..e18cf3d21 100644 --- a/src/content/Backgrounds/LetterGlitch/LetterGlitch.jsx +++ b/src/content/Backgrounds/LetterGlitch/LetterGlitch.jsx @@ -1,5 +1,7 @@ import { useRef, useEffect } from 'react'; +const FALLBACK_RGB = { r: 255, g: 255, b: 255 }; + const LetterGlitch = ({ glitchColors = ['#2b4539', '#61dca3', '#61b3dc'], className = '', @@ -48,14 +50,20 @@ const LetterGlitch = ({ : null; }; - const interpolateColor = (start, end, factor) => { - const result = { - r: Math.round(start.r + (end.r - start.r) * factor), - g: Math.round(start.g + (end.g - start.g) * factor), - b: Math.round(start.b + (end.b - start.b) * factor) - }; - return `rgb(${result.r}, ${result.g}, ${result.b})`; - }; + // Interpolation happens in numbers, and the CSS string is only built at + // paint time. Previously the formatted `rgb(...)` string was stored back + // on the letter and fed to hexToRgb on the next frame, which returned null + // and froze the transition after a single step. + const mixRgb = (start, end, factor) => ({ + r: Math.round(start.r + (end.r - start.r) * factor), + g: Math.round(start.g + (end.g - start.g) * factor), + b: Math.round(start.b + (end.b - start.b) * factor) + }); + + const rgbToCss = ({ r, g, b }) => `rgb(${r}, ${g}, ${b})`; + + // An unparseable entry in glitchColors must not stall the animation. + const getRandomRgb = () => hexToRgb(getRandomColor()) || FALLBACK_RGB; const calculateGrid = (width, height) => { const columns = Math.ceil(width / charWidth); @@ -66,12 +74,16 @@ const LetterGlitch = ({ const initializeLetters = (columns, rows) => { grid.current = { columns, rows }; const totalLetters = columns * rows; - letters.current = Array.from({ length: totalLetters }, () => ({ - char: getRandomChar(), - color: getRandomColor(), - targetColor: getRandomColor(), - colorProgress: 1 - })); + letters.current = Array.from({ length: totalLetters }, () => { + const rgb = getRandomRgb(); + return { + char: getRandomChar(), + rgb, + fromRgb: rgb, + targetRgb: getRandomRgb(), + colorProgress: 1 + }; + }); }; const resizeCanvas = () => { @@ -110,7 +122,7 @@ const LetterGlitch = ({ letters.current.forEach((letter, index) => { const x = (index % grid.current.columns) * charWidth; const y = Math.floor(index / grid.current.columns) * charHeight; - ctx.fillStyle = letter.color; + ctx.fillStyle = rgbToCss(letter.rgb); ctx.fillText(letter.char, x, y); }); }; @@ -125,10 +137,13 @@ const LetterGlitch = ({ if (!letters.current[index]) continue; letters.current[index].char = getRandomChar(); - letters.current[index].targetColor = getRandomColor(); + // A new transition starts from the colour currently on screen, so a + // letter picked again mid-fade continues instead of jumping. + letters.current[index].fromRgb = letters.current[index].rgb; + letters.current[index].targetRgb = getRandomRgb(); if (!smooth) { - letters.current[index].color = letters.current[index].targetColor; + letters.current[index].rgb = letters.current[index].targetRgb; letters.current[index].colorProgress = 1; } else { letters.current[index].colorProgress = 0; @@ -143,12 +158,8 @@ const LetterGlitch = ({ letter.colorProgress += 0.05; if (letter.colorProgress > 1) letter.colorProgress = 1; - const startRgb = hexToRgb(letter.color); - const endRgb = hexToRgb(letter.targetColor); - if (startRgb && endRgb) { - letter.color = interpolateColor(startRgb, endRgb, letter.colorProgress); - needsRedraw = true; - } + letter.rgb = mixRgb(letter.fromRgb, letter.targetRgb, letter.colorProgress); + needsRedraw = true; } }); diff --git a/src/tailwind/Backgrounds/LetterGlitch/LetterGlitch.jsx b/src/tailwind/Backgrounds/LetterGlitch/LetterGlitch.jsx index a7277d448..d46c54252 100644 --- a/src/tailwind/Backgrounds/LetterGlitch/LetterGlitch.jsx +++ b/src/tailwind/Backgrounds/LetterGlitch/LetterGlitch.jsx @@ -1,5 +1,7 @@ import { useRef, useEffect } from 'react'; +const FALLBACK_RGB = { r: 255, g: 255, b: 255 }; + const LetterGlitch = ({ glitchColors = ['#2b4539', '#61dca3', '#61b3dc'], glitchSpeed = 50, @@ -48,14 +50,20 @@ const LetterGlitch = ({ : null; }; - const interpolateColor = (start, end, factor) => { - const result = { - r: Math.round(start.r + (end.r - start.r) * factor), - g: Math.round(start.g + (end.g - start.g) * factor), - b: Math.round(start.b + (end.b - start.b) * factor) - }; - return `rgb(${result.r}, ${result.g}, ${result.b})`; - }; + // Interpolation happens in numbers, and the CSS string is only built at + // paint time. Previously the formatted `rgb(...)` string was stored back + // on the letter and fed to hexToRgb on the next frame, which returned null + // and froze the transition after a single step. + const mixRgb = (start, end, factor) => ({ + r: Math.round(start.r + (end.r - start.r) * factor), + g: Math.round(start.g + (end.g - start.g) * factor), + b: Math.round(start.b + (end.b - start.b) * factor) + }); + + const rgbToCss = ({ r, g, b }) => `rgb(${r}, ${g}, ${b})`; + + // An unparseable entry in glitchColors must not stall the animation. + const getRandomRgb = () => hexToRgb(getRandomColor()) || FALLBACK_RGB; const calculateGrid = (width, height) => { const columns = Math.ceil(width / charWidth); @@ -66,12 +74,16 @@ const LetterGlitch = ({ const initializeLetters = (columns, rows) => { grid.current = { columns, rows }; const totalLetters = columns * rows; - letters.current = Array.from({ length: totalLetters }, () => ({ - char: getRandomChar(), - color: getRandomColor(), - targetColor: getRandomColor(), - colorProgress: 1 - })); + letters.current = Array.from({ length: totalLetters }, () => { + const rgb = getRandomRgb(); + return { + char: getRandomChar(), + rgb, + fromRgb: rgb, + targetRgb: getRandomRgb(), + colorProgress: 1 + }; + }); }; const resizeCanvas = () => { @@ -110,7 +122,7 @@ const LetterGlitch = ({ letters.current.forEach((letter, index) => { const x = (index % grid.current.columns) * charWidth; const y = Math.floor(index / grid.current.columns) * charHeight; - ctx.fillStyle = letter.color; + ctx.fillStyle = rgbToCss(letter.rgb); ctx.fillText(letter.char, x, y); }); }; @@ -125,10 +137,13 @@ const LetterGlitch = ({ if (!letters.current[index]) continue; letters.current[index].char = getRandomChar(); - letters.current[index].targetColor = getRandomColor(); + // A new transition starts from the colour currently on screen, so a + // letter picked again mid-fade continues instead of jumping. + letters.current[index].fromRgb = letters.current[index].rgb; + letters.current[index].targetRgb = getRandomRgb(); if (!smooth) { - letters.current[index].color = letters.current[index].targetColor; + letters.current[index].rgb = letters.current[index].targetRgb; letters.current[index].colorProgress = 1; } else { letters.current[index].colorProgress = 0; @@ -143,12 +158,8 @@ const LetterGlitch = ({ letter.colorProgress += 0.05; if (letter.colorProgress > 1) letter.colorProgress = 1; - const startRgb = hexToRgb(letter.color); - const endRgb = hexToRgb(letter.targetColor); - if (startRgb && endRgb) { - letter.color = interpolateColor(startRgb, endRgb, letter.colorProgress); - needsRedraw = true; - } + letter.rgb = mixRgb(letter.fromRgb, letter.targetRgb, letter.colorProgress); + needsRedraw = true; } }); diff --git a/src/ts-default/Backgrounds/LetterGlitch/LetterGlitch.tsx b/src/ts-default/Backgrounds/LetterGlitch/LetterGlitch.tsx index 9ebc89234..3974b1363 100644 --- a/src/ts-default/Backgrounds/LetterGlitch/LetterGlitch.tsx +++ b/src/ts-default/Backgrounds/LetterGlitch/LetterGlitch.tsx @@ -1,5 +1,13 @@ import { useRef, useEffect } from 'react'; +interface Rgb { + r: number; + g: number; + b: number; +} + +const FALLBACK_RGB: Rgb = { r: 255, g: 255, b: 255 }; + const LetterGlitch = ({ glitchColors = ['#2b4539', '#61dca3', '#61b3dc'], glitchSpeed = 50, @@ -26,8 +34,9 @@ const LetterGlitch = ({ const letters = useRef< { char: string; - color: string; - targetColor: string; + rgb: Rgb; + fromRgb: Rgb; + targetRgb: Rgb; colorProgress: number; }[] >([]); @@ -65,18 +74,20 @@ const LetterGlitch = ({ : null; }; - const interpolateColor = ( - start: { r: number; g: number; b: number }, - end: { r: number; g: number; b: number }, - factor: number - ) => { - const result = { - r: Math.round(start.r + (end.r - start.r) * factor), - g: Math.round(start.g + (end.g - start.g) * factor), - b: Math.round(start.b + (end.b - start.b) * factor) - }; - return `rgb(${result.r}, ${result.g}, ${result.b})`; - }; + // Interpolation happens in numbers, and the CSS string is only built at + // paint time. Previously the formatted `rgb(...)` string was stored back + // on the letter and fed to hexToRgb on the next frame, which returned null + // and froze the transition after a single step. + const mixRgb = (start: Rgb, end: Rgb, factor: number): Rgb => ({ + r: Math.round(start.r + (end.r - start.r) * factor), + g: Math.round(start.g + (end.g - start.g) * factor), + b: Math.round(start.b + (end.b - start.b) * factor) + }); + + const rgbToCss = ({ r, g, b }: Rgb) => `rgb(${r}, ${g}, ${b})`; + + // An unparseable entry in glitchColors must not stall the animation. + const getRandomRgb = (): Rgb => hexToRgb(getRandomColor()) || FALLBACK_RGB; const calculateGrid = (width: number, height: number) => { const columns = Math.ceil(width / charWidth); @@ -87,12 +98,16 @@ const LetterGlitch = ({ const initializeLetters = (columns: number, rows: number) => { grid.current = { columns, rows }; const totalLetters = columns * rows; - letters.current = Array.from({ length: totalLetters }, () => ({ - char: getRandomChar(), - color: getRandomColor(), - targetColor: getRandomColor(), - colorProgress: 1 - })); + letters.current = Array.from({ length: totalLetters }, () => { + const rgb = getRandomRgb(); + return { + char: getRandomChar(), + rgb, + fromRgb: rgb, + targetRgb: getRandomRgb(), + colorProgress: 1 + }; + }); }; const resizeCanvas = () => { @@ -130,7 +145,7 @@ const LetterGlitch = ({ letters.current.forEach((letter, index) => { const x = (index % grid.current.columns) * charWidth; const y = Math.floor(index / grid.current.columns) * charHeight; - ctx.fillStyle = letter.color; + ctx.fillStyle = rgbToCss(letter.rgb); ctx.fillText(letter.char, x, y); }); }; @@ -145,10 +160,13 @@ const LetterGlitch = ({ if (!letters.current[index]) continue; letters.current[index].char = getRandomChar(); - letters.current[index].targetColor = getRandomColor(); + // A new transition starts from the colour currently on screen, so a + // letter picked again mid-fade continues instead of jumping. + letters.current[index].fromRgb = letters.current[index].rgb; + letters.current[index].targetRgb = getRandomRgb(); if (!smooth) { - letters.current[index].color = letters.current[index].targetColor; + letters.current[index].rgb = letters.current[index].targetRgb; letters.current[index].colorProgress = 1; } else { letters.current[index].colorProgress = 0; @@ -163,12 +181,8 @@ const LetterGlitch = ({ letter.colorProgress += 0.05; if (letter.colorProgress > 1) letter.colorProgress = 1; - const startRgb = hexToRgb(letter.color); - const endRgb = hexToRgb(letter.targetColor); - if (startRgb && endRgb) { - letter.color = interpolateColor(startRgb, endRgb, letter.colorProgress); - needsRedraw = true; - } + letter.rgb = mixRgb(letter.fromRgb, letter.targetRgb, letter.colorProgress); + needsRedraw = true; } }); diff --git a/src/ts-tailwind/Backgrounds/LetterGlitch/LetterGlitch.tsx b/src/ts-tailwind/Backgrounds/LetterGlitch/LetterGlitch.tsx index f9cb8f375..41fb5c0b7 100644 --- a/src/ts-tailwind/Backgrounds/LetterGlitch/LetterGlitch.tsx +++ b/src/ts-tailwind/Backgrounds/LetterGlitch/LetterGlitch.tsx @@ -1,5 +1,13 @@ import { useRef, useEffect } from 'react'; +interface Rgb { + r: number; + g: number; + b: number; +} + +const FALLBACK_RGB: Rgb = { r: 255, g: 255, b: 255 }; + const LetterGlitch = ({ glitchColors = ['#2b4539', '#61dca3', '#61b3dc'], glitchSpeed = 50, @@ -26,8 +34,9 @@ const LetterGlitch = ({ const letters = useRef< { char: string; - color: string; - targetColor: string; + rgb: Rgb; + fromRgb: Rgb; + targetRgb: Rgb; colorProgress: number; }[] >([]); @@ -65,18 +74,20 @@ const LetterGlitch = ({ : null; }; - const interpolateColor = ( - start: { r: number; g: number; b: number }, - end: { r: number; g: number; b: number }, - factor: number - ) => { - const result = { - r: Math.round(start.r + (end.r - start.r) * factor), - g: Math.round(start.g + (end.g - start.g) * factor), - b: Math.round(start.b + (end.b - start.b) * factor) - }; - return `rgb(${result.r}, ${result.g}, ${result.b})`; - }; + // Interpolation happens in numbers, and the CSS string is only built at + // paint time. Previously the formatted `rgb(...)` string was stored back + // on the letter and fed to hexToRgb on the next frame, which returned null + // and froze the transition after a single step. + const mixRgb = (start: Rgb, end: Rgb, factor: number): Rgb => ({ + r: Math.round(start.r + (end.r - start.r) * factor), + g: Math.round(start.g + (end.g - start.g) * factor), + b: Math.round(start.b + (end.b - start.b) * factor) + }); + + const rgbToCss = ({ r, g, b }: Rgb) => `rgb(${r}, ${g}, ${b})`; + + // An unparseable entry in glitchColors must not stall the animation. + const getRandomRgb = (): Rgb => hexToRgb(getRandomColor()) || FALLBACK_RGB; const calculateGrid = (width: number, height: number) => { const columns = Math.ceil(width / charWidth); @@ -87,12 +98,16 @@ const LetterGlitch = ({ const initializeLetters = (columns: number, rows: number) => { grid.current = { columns, rows }; const totalLetters = columns * rows; - letters.current = Array.from({ length: totalLetters }, () => ({ - char: getRandomChar(), - color: getRandomColor(), - targetColor: getRandomColor(), - colorProgress: 1 - })); + letters.current = Array.from({ length: totalLetters }, () => { + const rgb = getRandomRgb(); + return { + char: getRandomChar(), + rgb, + fromRgb: rgb, + targetRgb: getRandomRgb(), + colorProgress: 1 + }; + }); }; const resizeCanvas = () => { @@ -130,7 +145,7 @@ const LetterGlitch = ({ letters.current.forEach((letter, index) => { const x = (index % grid.current.columns) * charWidth; const y = Math.floor(index / grid.current.columns) * charHeight; - ctx.fillStyle = letter.color; + ctx.fillStyle = rgbToCss(letter.rgb); ctx.fillText(letter.char, x, y); }); }; @@ -145,10 +160,13 @@ const LetterGlitch = ({ if (!letters.current[index]) continue; letters.current[index].char = getRandomChar(); - letters.current[index].targetColor = getRandomColor(); + // A new transition starts from the colour currently on screen, so a + // letter picked again mid-fade continues instead of jumping. + letters.current[index].fromRgb = letters.current[index].rgb; + letters.current[index].targetRgb = getRandomRgb(); if (!smooth) { - letters.current[index].color = letters.current[index].targetColor; + letters.current[index].rgb = letters.current[index].targetRgb; letters.current[index].colorProgress = 1; } else { letters.current[index].colorProgress = 0; @@ -163,12 +181,8 @@ const LetterGlitch = ({ letter.colorProgress += 0.05; if (letter.colorProgress > 1) letter.colorProgress = 1; - const startRgb = hexToRgb(letter.color); - const endRgb = hexToRgb(letter.targetColor); - if (startRgb && endRgb) { - letter.color = interpolateColor(startRgb, endRgb, letter.colorProgress); - needsRedraw = true; - } + letter.rgb = mixRgb(letter.fromRgb, letter.targetRgb, letter.colorProgress); + needsRedraw = true; } });