mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 06:29:30 -07:00
1 line
18 KiB
JSON
1 line
18 KiB
JSON
{"name":"ASCIIText","title":"ASCIIText","description":"Renders text with an animated ASCII background for a retro feel.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<!-- Component ported and enhanced from https://codepen.io/JuanFuentes/pen/eYEeoyE -->\n\n<script setup lang=\"ts\">\nimport { onMounted, onUnmounted, watch, useTemplateRef } from 'vue';\nimport * as THREE from 'three';\n\ninterface ASCIITextProps {\n text?: string;\n asciiFontSize?: number;\n textFontSize?: number;\n textColor?: string;\n planeBaseHeight?: number;\n enableWaves?: boolean;\n className?: string;\n}\n\nconst props = withDefaults(defineProps<ASCIITextProps>(), {\n text: 'David!',\n asciiFontSize: 8,\n textFontSize: 200,\n textColor: '#fdf9f3',\n planeBaseHeight: 8,\n enableWaves: true,\n className: ''\n});\n\nconst vertexShader = `\nvarying vec2 vUv;\nuniform float uTime;\nuniform float mouse;\nuniform float uEnableWaves;\n\nvoid main() {\n vUv = uv;\n float time = uTime * 5.;\n\n float waveFactor = uEnableWaves;\n\n vec3 transformed = position;\n\n transformed.x += sin(time + position.y) * 0.5 * waveFactor;\n transformed.y += cos(time + position.z) * 0.15 * waveFactor;\n transformed.z += sin(time + position.x) * waveFactor;\n\n gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0);\n}\n`;\n\nconst fragmentShader = `\nvarying vec2 vUv;\nuniform float mouse;\nuniform float uTime;\nuniform sampler2D uTexture;\n\nvoid main() {\n float time = uTime;\n vec2 pos = vUv;\n \n float move = sin(time + mouse) * 0.01;\n float r = texture2D(uTexture, pos + cos(time * 2. - time + pos.x) * .01).r;\n float g = texture2D(uTexture, pos + tan(time * .5 + pos.x - time) * .01).g;\n float b = texture2D(uTexture, pos - cos(time * 2. + time + pos.y) * .01).b;\n float a = texture2D(uTexture, pos).a;\n gl_FragColor = vec4(r, g, b, a);\n}\n`;\n\n// @ts-expect-error - Adding map function to Math object\nMath.map = function (n: number, start: number, stop: number, start2: number, stop2: number) {\n return ((n - start) / (stop - start)) * (stop2 - start2) + start2;\n};\n\nconst PX_RATIO = typeof window !== 'undefined' ? window.devicePixelRatio : 1;\n\ninterface AsciiFilterOptions {\n fontSize?: number;\n fontFamily?: string;\n charset?: string;\n invert?: boolean;\n}\n\ninterface CanvasTxtOptions {\n fontSize?: number;\n fontFamily?: string;\n color?: string;\n}\n\nclass AsciiFilter {\n renderer: THREE.WebGLRenderer;\n domElement: HTMLDivElement;\n pre: HTMLPreElement;\n canvas: HTMLCanvasElement;\n context: CanvasRenderingContext2D | null;\n deg: number;\n invert: boolean;\n fontSize: number;\n fontFamily: string;\n charset: string;\n width: number = 0;\n height: number = 0;\n center: { x: number; y: number } = { x: 0, y: 0 };\n mouse: { x: number; y: number } = { x: 0, y: 0 };\n cols: number = 0;\n rows: number = 0;\n\n constructor(renderer: THREE.WebGLRenderer, { fontSize, fontFamily, charset, invert }: AsciiFilterOptions = {}) {\n this.renderer = renderer;\n this.domElement = document.createElement('div');\n this.domElement.style.position = 'absolute';\n this.domElement.style.top = '0';\n this.domElement.style.left = '0';\n this.domElement.style.width = '100%';\n this.domElement.style.height = '100%';\n\n this.pre = document.createElement('pre');\n this.domElement.appendChild(this.pre);\n\n this.canvas = document.createElement('canvas');\n this.context = this.canvas.getContext('2d');\n this.domElement.appendChild(this.canvas);\n\n this.deg = 0;\n this.invert = invert ?? true;\n this.fontSize = fontSize ?? 12;\n this.fontFamily = fontFamily ?? \"'Courier New', monospace\";\n this.charset = charset ?? ' .\\'`^\",:;Il!i~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$';\n\n if (this.context) {\n this.context.imageSmoothingEnabled = false;\n }\n\n this.onMouseMove = this.onMouseMove.bind(this);\n document.addEventListener('mousemove', this.onMouseMove);\n }\n\n setSize(width: number, height: number) {\n this.width = width;\n this.height = height;\n this.renderer.setSize(width, height);\n this.reset();\n\n this.center = { x: width / 2, y: height / 2 };\n this.mouse = { x: this.center.x, y: this.center.y };\n }\n\n reset() {\n this.context!.font = `${this.fontSize}px ${this.fontFamily}`;\n\n const testChar = 'M';\n const charMetrics = this.context!.measureText(testChar);\n const charWidth = charMetrics.width;\n const charHeight = this.fontSize;\n\n this.cols = Math.floor(this.width / charWidth);\n this.rows = Math.floor(this.height / charHeight);\n\n this.canvas.width = this.cols;\n this.canvas.height = this.rows;\n\n const totalWidth = this.cols * charWidth;\n const totalHeight = this.rows * charHeight;\n const offsetX = (this.width - totalWidth) / 2;\n const offsetY = (this.height - totalHeight) / 2;\n\n this.pre.style.fontFamily = this.fontFamily;\n this.pre.style.fontSize = `${this.fontSize}px`;\n this.pre.style.margin = '0';\n this.pre.style.padding = '0';\n this.pre.style.lineHeight = `${this.fontSize}px`;\n this.pre.style.position = 'absolute';\n this.pre.style.left = `${offsetX}px`;\n this.pre.style.top = `${offsetY}px`;\n this.pre.style.width = `${totalWidth}px`;\n this.pre.style.height = `${totalHeight}px`;\n this.pre.style.letterSpacing = '0';\n this.pre.style.wordSpacing = '0';\n this.pre.style.whiteSpace = 'pre';\n this.pre.style.overflow = 'hidden';\n this.pre.style.zIndex = '9';\n this.pre.style.backgroundImage = 'radial-gradient(circle, #ff6188 0%, #fc9867 50%, #ffd866 100%)';\n this.pre.style.backgroundAttachment = 'fixed';\n this.pre.style.webkitTextFillColor = 'transparent';\n this.pre.style.webkitBackgroundClip = 'text';\n this.pre.style.backgroundClip = 'text';\n this.pre.style.mixBlendMode = 'difference';\n }\n\n onMouseMove(e: MouseEvent) {\n this.mouse = { x: e.clientX * PX_RATIO, y: e.clientY * PX_RATIO };\n }\n\n render(scene: THREE.Scene, camera: THREE.Camera) {\n this.renderer.render(scene, camera);\n\n const w = this.canvas.width;\n const h = this.canvas.height;\n this.context!.clearRect(0, 0, w, h);\n if (this.context && w && h) {\n this.context.drawImage(this.renderer.domElement, 0, 0, w, h);\n }\n\n this.asciify(this.context!, w, h);\n this.hue();\n }\n\n asciify(ctx: CanvasRenderingContext2D, w: number, h: number) {\n if (w && h) {\n const imgData = ctx.getImageData(0, 0, w, h).data;\n let str = '';\n for (let y = 0; y < h; y++) {\n for (let x = 0; x < w; x++) {\n const i = x * 4 + y * 4 * w;\n const [r, g, b, a] = [imgData[i], imgData[i + 1], imgData[i + 2], imgData[i + 3]];\n\n if (a === 0) {\n str += ' ';\n continue;\n }\n\n const gray = (0.3 * r + 0.6 * g + 0.1 * b) / 255;\n let idx = Math.floor((1 - gray) * (this.charset.length - 1));\n if (this.invert) idx = this.charset.length - idx - 1;\n str += this.charset[idx];\n }\n str += '\\n';\n }\n this.pre.innerHTML = str;\n }\n }\n\n get dx() {\n return this.mouse.x - this.center.x;\n }\n\n get dy() {\n return this.mouse.y - this.center.y;\n }\n\n hue() {\n const deg = (Math.atan2(this.dy, this.dx) * 180) / Math.PI;\n this.deg += (deg - this.deg) * 0.075;\n this.domElement.style.filter = `hue-rotate(${this.deg.toFixed(1)}deg)`;\n }\n\n dispose() {\n document.removeEventListener('mousemove', this.onMouseMove);\n }\n}\n\nclass CanvasTxt {\n canvas: HTMLCanvasElement;\n context: CanvasRenderingContext2D | null;\n txt: string;\n fontSize: number;\n fontFamily: string;\n color: string;\n font: string;\n\n constructor(txt: string, { fontSize = 200, fontFamily = 'Arial', color = '#fdf9f3' }: CanvasTxtOptions = {}) {\n this.canvas = document.createElement('canvas');\n this.context = this.canvas.getContext('2d');\n this.txt = txt;\n this.fontSize = fontSize;\n this.fontFamily = fontFamily;\n this.color = color;\n this.font = `600 ${this.fontSize}px ${this.fontFamily}`;\n }\n\n resize() {\n if (this.context) {\n this.context.font = this.font;\n const metrics = this.context.measureText(this.txt);\n\n const textWidth = Math.ceil(metrics.width) + 20;\n const textHeight = Math.ceil(metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent) + 20;\n\n this.canvas.width = textWidth;\n this.canvas.height = textHeight;\n }\n }\n\n render() {\n if (this.context) {\n this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);\n this.context.fillStyle = this.color;\n this.context.font = this.font;\n\n const metrics = this.context.measureText(this.txt);\n const yPos = 10 + metrics.actualBoundingBoxAscent;\n\n this.context.fillText(this.txt, 10, yPos);\n }\n }\n\n get width() {\n return this.canvas.width;\n }\n\n get height() {\n return this.canvas.height;\n }\n\n get texture() {\n return this.canvas;\n }\n}\n\nclass CanvAscii {\n textString: string;\n asciiFontSize: number;\n textFontSize: number;\n textColor!: string;\n planeBaseHeight!: number;\n container!: HTMLElement;\n width!: number;\n height!: number;\n enableWaves!: boolean;\n camera!: THREE.PerspectiveCamera;\n scene!: THREE.Scene;\n mouse: { x: number; y: number } = { x: 0, y: 0 };\n textCanvas!: CanvasTxt;\n texture!: THREE.CanvasTexture;\n geometry!: THREE.PlaneGeometry;\n material!: THREE.ShaderMaterial;\n mesh!: THREE.Mesh;\n renderer!: THREE.WebGLRenderer;\n filter!: AsciiFilter;\n center: { x: number; y: number } = { x: 0, y: 0 };\n animationFrameId: number = 0;\n\n constructor(\n {\n text,\n asciiFontSize,\n textFontSize,\n textColor,\n planeBaseHeight,\n enableWaves\n }: {\n text: string;\n asciiFontSize: number;\n textFontSize: number;\n textColor: string;\n planeBaseHeight: number;\n enableWaves: boolean;\n },\n containerElem: HTMLElement,\n width: number,\n height: number\n ) {\n this.textString = text;\n this.asciiFontSize = asciiFontSize;\n this.textFontSize = textFontSize;\n this.textColor = textColor;\n this.planeBaseHeight = planeBaseHeight;\n this.container = containerElem;\n this.width = width;\n this.height = height;\n this.enableWaves = enableWaves;\n\n this.camera = new THREE.PerspectiveCamera(45, this.width / this.height, 1, 1000);\n this.camera.position.z = 30;\n\n this.scene = new THREE.Scene();\n\n this.onMouseMove = this.onMouseMove.bind(this);\n this.setMesh();\n this.setRenderer();\n }\n\n setMesh() {\n this.textCanvas = new CanvasTxt(this.textString, {\n fontSize: this.textFontSize,\n fontFamily: 'IBM Plex Mono',\n color: this.textColor\n });\n this.textCanvas.resize();\n this.textCanvas.render();\n\n this.texture = new THREE.CanvasTexture(this.textCanvas.texture);\n this.texture.minFilter = THREE.NearestFilter;\n\n const textAspect = this.textCanvas.width / this.textCanvas.height;\n const baseH = this.planeBaseHeight;\n const planeW = baseH * textAspect;\n const planeH = baseH;\n\n this.geometry = new THREE.PlaneGeometry(planeW, planeH, 36, 36);\n this.material = new THREE.ShaderMaterial({\n vertexShader,\n fragmentShader,\n transparent: true,\n uniforms: {\n uTime: { value: 0 },\n mouse: { value: 1.0 },\n uTexture: { value: this.texture },\n uEnableWaves: { value: this.enableWaves ? 1.0 : 0.0 }\n }\n });\n\n this.mesh = new THREE.Mesh(this.geometry, this.material);\n this.scene.add(this.mesh);\n }\n\n setRenderer() {\n this.renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true });\n this.renderer.setPixelRatio(1);\n this.renderer.setClearColor(0x000000, 0);\n\n this.filter = new AsciiFilter(this.renderer, {\n fontFamily: 'IBM Plex Mono',\n fontSize: this.asciiFontSize,\n invert: true\n });\n\n this.container.appendChild(this.filter.domElement);\n this.setSize(this.width, this.height);\n\n this.container.addEventListener('mousemove', this.onMouseMove);\n this.container.addEventListener('touchmove', this.onMouseMove);\n }\n\n setSize(w: number, h: number) {\n this.width = w;\n this.height = h;\n\n this.camera.aspect = w / h;\n this.camera.updateProjectionMatrix();\n\n this.filter.setSize(w, h);\n this.center = { x: w / 2, y: h / 2 };\n }\n\n load() {\n this.animate();\n }\n\n onMouseMove(evt: MouseEvent | TouchEvent) {\n const e = 'touches' in evt ? evt.touches[0] : evt;\n const bounds = this.container.getBoundingClientRect();\n const x = e.clientX - bounds.left;\n const y = e.clientY - bounds.top;\n this.mouse = { x, y };\n }\n\n animate() {\n const animateFrame = () => {\n this.animationFrameId = requestAnimationFrame(animateFrame);\n this.render();\n };\n animateFrame();\n }\n\n render() {\n const time = new Date().getTime() * 0.001;\n\n this.textCanvas.render();\n this.texture.needsUpdate = true;\n (this.mesh.material as THREE.ShaderMaterial).uniforms.uTime.value = Math.sin(time);\n\n this.updateRotation();\n this.filter.render(this.scene, this.camera);\n }\n\n updateRotation() {\n // @ts-expect-error - Using custom Math.map function\n const x = Math.map(this.mouse.y, 0, this.height, 0.5, -0.5);\n // @ts-expect-error - Using custom Math.map function\n const y = Math.map(this.mouse.x, 0, this.width, -0.5, 0.5);\n\n this.mesh.rotation.x += (x - this.mesh.rotation.x) * 0.05;\n this.mesh.rotation.y += (y - this.mesh.rotation.y) * 0.05;\n }\n\n clear() {\n this.scene.traverse(obj => {\n if (obj instanceof THREE.Mesh && obj.material && obj.geometry) {\n if (Array.isArray(obj.material)) {\n obj.material.forEach(mat => mat.dispose());\n } else {\n obj.material.dispose();\n }\n obj.geometry.dispose();\n }\n });\n this.scene.clear();\n }\n\n dispose() {\n cancelAnimationFrame(this.animationFrameId);\n this.filter.dispose();\n this.container.removeChild(this.filter.domElement);\n this.container.removeEventListener('mousemove', this.onMouseMove);\n this.container.removeEventListener('touchmove', this.onMouseMove);\n this.clear();\n this.renderer.dispose();\n }\n}\n\nconst containerRef = useTemplateRef<HTMLDivElement>('containerRef');\nlet asciiRef: CanvAscii | null = null;\n\nconst initializeAscii = () => {\n if (!containerRef.value) return;\n\n const { width, height } = containerRef.value.getBoundingClientRect();\n\n if (width === 0 || height === 0) {\n const observer = new IntersectionObserver(\n ([entry]) => {\n if (entry.isIntersecting && entry.boundingClientRect.width > 0 && entry.boundingClientRect.height > 0) {\n const { width: w, height: h } = entry.boundingClientRect;\n\n asciiRef = new CanvAscii(\n {\n text: props.text,\n asciiFontSize: props.asciiFontSize,\n textFontSize: props.textFontSize,\n textColor: props.textColor,\n planeBaseHeight: props.planeBaseHeight,\n enableWaves: props.enableWaves\n },\n containerRef.value!,\n w,\n h\n );\n asciiRef.load();\n\n observer.disconnect();\n }\n },\n { threshold: 0.1 }\n );\n\n observer.observe(containerRef.value);\n return;\n }\n\n asciiRef = new CanvAscii(\n {\n text: props.text,\n asciiFontSize: props.asciiFontSize,\n textFontSize: props.textFontSize,\n textColor: props.textColor,\n planeBaseHeight: props.planeBaseHeight,\n enableWaves: props.enableWaves\n },\n containerRef.value,\n width,\n height\n );\n asciiRef.load();\n\n const ro = new ResizeObserver(entries => {\n if (!entries[0] || !asciiRef) return;\n const { width: w, height: h } = entries[0].contentRect;\n if (w > 0 && h > 0) {\n asciiRef.setSize(w, h);\n }\n });\n ro.observe(containerRef.value);\n};\n\nonMounted(() => {\n initializeAscii();\n});\n\nonUnmounted(() => {\n if (asciiRef) {\n asciiRef.dispose();\n }\n});\n\nwatch(\n () => [\n props.text,\n props.asciiFontSize,\n props.textFontSize,\n props.textColor,\n props.planeBaseHeight,\n props.enableWaves\n ],\n () => {\n if (asciiRef) {\n asciiRef.dispose();\n }\n initializeAscii();\n }\n);\n</script>\n\n<template>\n <div ref=\"containerRef\" :class=\"['ascii-text-container', className]\" class=\"absolute inset-0 w-full h-full\" />\n</template>\n\n<style scoped>\n@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500&display=swap');\n\n.ascii-text-container :deep(canvas) {\n position: absolute;\n left: 0;\n top: 0;\n width: 100%;\n height: 100%;\n image-rendering: optimizeSpeed;\n image-rendering: -moz-crisp-edges;\n image-rendering: -o-crisp-edges;\n image-rendering: -webkit-optimize-contrast;\n image-rendering: optimize-contrast;\n image-rendering: crisp-edges;\n image-rendering: pixelated;\n}\n\n.ascii-text-container :deep(pre) {\n margin: 0;\n user-select: none;\n padding: 0;\n position: absolute;\n white-space: pre;\n overflow: hidden;\n background-image: radial-gradient(circle, #ff6188 0%, #fc9867 50%, #ffd866 100%);\n background-attachment: fixed;\n -webkit-text-fill-color: transparent;\n -webkit-background-clip: text;\n background-clip: text;\n z-index: 9;\n mix-blend-mode: difference;\n font-variant-ligatures: none;\n font-feature-settings: 'liga' 0;\n}\n</style>\n","path":"ASCIIText/ASCIIText.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[{"ecosystem":"js","name":"three","version":"^0.178.0"}],"devDependencies":[],"categories":["TextAnimations"]} |