diff --git a/content/docs/components/ascii-logo.mdx b/content/docs/components/ascii-logo.mdx index 8544ecf..a210f17 100644 --- a/content/docs/components/ascii-logo.mdx +++ b/content/docs/components/ascii-logo.mdx @@ -50,6 +50,8 @@ export function FooterMark() { +`text` is limited to 5 letters. Extra characters are dropped. + Pass `src` to sample an image instead of text — any raster, or a same-origin SVG. @@ -71,7 +73,7 @@ Clicks cycle `logo → scattered → fallen → returning → logo`. Hover repul | Prop | Type | Default | | ----------------- | ----------------------------- | ------------------------------- | -| `text` | `string` | `"23rd"` | +| `text` | `string` | `"23rd"` (max 5 letters) | | `src` | `string` | — | | `fit` | `number` | `0.82` | | `cellSize` | `number` | `11` | diff --git a/public/r/ascii-logo-svelte.json b/public/r/ascii-logo-svelte.json index fe59553..ffbda2e 100644 --- a/public/r/ascii-logo-svelte.json +++ b/public/r/ascii-logo-svelte.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/ascii-logo/ascii-logo.svelte", - "content": "\n\n\n\n\n \n\n", + "content": "\n\n\n\n\n \n\n", "type": "registry:file", "target": "src/lib/components/ui/ascii-logo.svelte" } diff --git a/public/r/ascii-logo.json b/public/r/ascii-logo.json index 48ac69f..021caec 100644 --- a/public/r/ascii-logo.json +++ b/public/r/ascii-logo.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/ascii-logo/ascii-logo.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type AsciiLogoPhase = \"logo\" | \"scattered\" | \"fallen\" | \"returning\"\n\nexport type AsciiLogoTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type AsciiLogoOptions = {\n /**\n * Wordmark sampled into the ASCII grid. Ignored when `src` is set.\n * Default `\"23rd\"`\n */\n text?: string\n /** Image URL to sample instead of `text` (any raster or same-origin SVG). */\n src?: string\n /**\n * How much of the stage the source covers (0–1). Default `0.82`\n */\n fit?: number\n /** Glyph cell size in CSS pixels. Default `11` */\n cellSize?: number\n /** Gap between cells in CSS pixels. Default `2` */\n cellGap?: number\n /** Pool of glyphs. One is picked at random per cell. */\n charset?: string\n /**\n * Brightness (0–1) a sample must clear to become a glyph.\n * Default `0.2`\n */\n threshold?: number\n /**\n * Treat dark pixels as solid. Default `true` when `src` is set,\n * `false` for text (white ink on a black sampler).\n */\n invert?: boolean\n /** Glyph color (hex). Default follows theme. */\n color?: string\n /** Stage color (hex). Pass `\"transparent\"` to skip the fill. */\n backgroundColor?: string\n /** Cursor repulsion radius in grid cells. Default `7` */\n hoverRadius?: number\n /** How far glyphs shove away from the cursor. Default `2.6` */\n hoverPush?: number\n /** Hover ease (0–1). Default `0.18` */\n hoverEase?: number\n /** Max scatter offset in grid cells. Default `16` */\n scatterRange?: number\n /** Scatter ease (0–1). Default `0.055` */\n scatterEase?: number\n /** Fall acceleration in cells / frame @ 60fps. Default `0.14` */\n gravity?: number\n /** Bounce restitution (0–1). Default `0.28` */\n bounce?: number\n /** Reassemble ease (0–1). Default `0.08` */\n resetEase?: number\n /** Max frames a glyph waits before moving. Default `18` */\n staggerFrames?: number\n /** Pointer hover + click cycle. Default `true` */\n interactive?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: AsciiLogoTheme\n /** Fires after each phase change (including the auto-return to `logo`). */\n onPhaseChange?: (phase: AsciiLogoPhase) => void\n}\n\nexport type AsciiLogoInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\ntype AsciiCell = {\n col: number\n row: number\n char: string\n offsetX: number\n offsetY: number\n scatterX: number\n scatterY: number\n fallSpeed: number\n wait: number\n}\n\nexport const DEFAULT_CHARSET =\n \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%&*\"\n\nconst LIGHT = { ink: \"#3f3f46\", paper: \"#fafafa\" }\nconst DARK = { ink: \"#a1a1aa\", paper: \"#09090b\" }\n\nconst TEXT_FONT =\n '900 1px \"Arial Black\", Impact, Arial, ui-sans-serif, system-ui, sans-serif'\n\nfunction isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nfunction resolveDark(theme: AsciiLogoTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction pickChar(charset: string) {\n const pool = charset.length > 0 ? charset : DEFAULT_CHARSET\n return pool[Math.floor(Math.random() * pool.length)] ?? \"#\"\n}\n\nfunction loadImage(src: string): Promise {\n return new Promise((resolve, reject) => {\n const img = new Image()\n img.decoding = \"async\"\n if (/^https?:/i.test(src) && !src.startsWith(window.location.origin)) {\n img.crossOrigin = \"anonymous\"\n }\n img.onload = () => resolve(img)\n img.onerror = () => reject(new Error(\"AsciiLogo: failed to load image\"))\n img.src = src\n })\n}\n\nfunction easeToward(\n cell: AsciiCell,\n targetX: number,\n targetY: number,\n ease: number\n) {\n cell.offsetX += (targetX - cell.offsetX) * ease\n cell.offsetY += (targetY - cell.offsetY) * ease\n}\n\nfunction frameEase(ease: number, frames: number) {\n const e = Math.min(1, Math.max(0, ease))\n if (frames <= 0) return e\n return 1 - Math.pow(1 - e, frames)\n}\n\nfunction staggerCells(cells: AsciiCell[], staggerFrames: number) {\n const max = Math.max(0, staggerFrames)\n for (const cell of cells) {\n cell.wait = Math.random() * max\n }\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function createAsciiLogo(\n root: HTMLElement,\n canvas: HTMLCanvasElement,\n initial: AsciiLogoOptions = {}\n): AsciiLogoInstance | null {\n let options: Required<\n Pick<\n AsciiLogoOptions,\n | \"text\"\n | \"fit\"\n | \"cellSize\"\n | \"cellGap\"\n | \"charset\"\n | \"threshold\"\n | \"hoverRadius\"\n | \"hoverPush\"\n | \"hoverEase\"\n | \"scatterRange\"\n | \"scatterEase\"\n | \"gravity\"\n | \"bounce\"\n | \"resetEase\"\n | \"staggerFrames\"\n | \"interactive\"\n | \"theme\"\n >\n > &\n AsciiLogoOptions = {\n text: \"23rd\",\n fit: 0.82,\n cellSize: 11,\n cellGap: 2,\n charset: DEFAULT_CHARSET,\n threshold: 0.2,\n hoverRadius: 7,\n hoverPush: 2.6,\n hoverEase: 0.18,\n scatterRange: 16,\n scatterEase: 0.055,\n gravity: 0.14,\n bounce: 0.28,\n resetEase: 0.08,\n staggerFrames: 18,\n interactive: true,\n theme: \"auto\",\n ...initial,\n }\n\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let raf = 0\n let running = true\n let last = performance.now()\n let gridRows = 0\n let cells: AsciiCell[] = []\n let phase: AsciiLogoPhase = \"logo\"\n let lastKey = \"\"\n let lastW = -1\n let lastH = -1\n let lastDpr = -1\n let loadId = 0\n let reduce = false\n const cursor = { x: -999, y: -999 }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const setPhase = (next: AsciiLogoPhase) => {\n if (phase === next) return\n phase = next\n options.onPhaseChange?.(next)\n }\n\n const sizeCanvas = () => {\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return { w: 0, h: 0 }\n if (w === lastW && h === lastH && dpr === lastDpr) return { w, h }\n lastW = w\n lastH = h\n lastDpr = dpr\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n return { w, h }\n }\n\n const sampleSource = (\n sampler: CanvasRenderingContext2D,\n cols: number,\n rows: number,\n image: HTMLImageElement | null,\n p: typeof options\n ) => {\n sampler.fillStyle = \"#000\"\n sampler.fillRect(0, 0, cols, rows)\n\n const cover = Math.min(1, Math.max(0.2, p.fit))\n if (image && image.width > 0 && image.height > 0) {\n const maxW = cols * cover\n const maxH = rows * cover\n const scale = Math.min(maxW / image.width, maxH / image.height)\n const dw = image.width * scale\n const dh = image.height * scale\n sampler.drawImage(image, (cols - dw) / 2, (rows - dh) / 2, dw, dh)\n return\n }\n\n const word = p.text.trim()\n if (!word) return\n sampler.fillStyle = \"#fff\"\n sampler.textAlign = \"center\"\n sampler.textBaseline = \"middle\"\n let fontSize = rows * 0.52\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n const width = sampler.measureText(word).width\n const maxW = cols * cover\n if (width > maxW && width > 0) {\n fontSize *= maxW / width\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n }\n sampler.fillText(word, cols / 2, rows / 2 + fontSize * 0.04)\n }\n\n const buildFromImageData = (\n data: ImageData,\n cols: number,\n rows: number,\n p: typeof options\n ) => {\n const shouldInvert = p.invert ?? Boolean(p.src)\n const lit = new Set()\n const pixels = data.data\n for (let row = 0; row < rows; row++) {\n for (let col = 0; col < cols; col++) {\n const i = (row * cols + col) * 4\n const r = pixels[i] ?? 0\n const g = pixels[i + 1] ?? 0\n const b = pixels[i + 2] ?? 0\n const a = (pixels[i + 3] ?? 0) / 255\n const luma = (r * 0.299 + g * 0.587 + b * 0.114) / 255\n const value = (shouldInvert ? 1 - luma : luma) * a\n if (value < p.threshold) continue\n lit.add(`${col},${row}`)\n if (col + 1 < cols) lit.add(`${col + 1},${row}`)\n }\n }\n\n const next: AsciiCell[] = []\n for (const key of lit) {\n const [colStr, rowStr] = key.split(\",\")\n const col = Number(colStr)\n const row = Number(rowStr)\n next.push({\n col,\n row,\n char: pickChar(p.charset),\n offsetX: 0,\n offsetY: 0,\n scatterX: 0,\n scatterY: 0,\n fallSpeed: 0,\n wait: 0,\n })\n }\n return next\n }\n\n const gridKey = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return \"\"\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n return [\n p.src ?? \"\",\n p.text,\n p.fit,\n p.cellSize,\n p.cellGap,\n p.charset,\n p.threshold,\n String(p.invert ?? \"\"),\n cols,\n rows,\n ].join(\"|\")\n }\n\n const rebuild = async () => {\n const p = options\n const { w, h } = sizeCanvas()\n if (w <= 0 || h <= 0) return\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n const key = gridKey()\n if (!key || key === lastKey) return\n lastKey = key\n const id = ++loadId\n\n let image: HTMLImageElement | null = null\n if (p.src) {\n try {\n image = await loadImage(p.src)\n } catch {\n image = null\n }\n if (id !== loadId || !running) return\n }\n\n const sampler = document.createElement(\"canvas\")\n sampler.width = cols\n sampler.height = rows\n const samplerCtx = sampler.getContext(\"2d\", { willReadFrequently: true })\n if (!samplerCtx) return\n const snapshot = {\n ...options,\n src: image ? options.src : undefined,\n }\n sampleSource(samplerCtx, cols, rows, image, snapshot)\n let data: ImageData | null = null\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n data = null\n }\n if (!data && image) {\n sampleSource(samplerCtx, cols, rows, null, {\n ...snapshot,\n src: undefined,\n })\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n lastKey = \"\"\n return\n }\n }\n if (!data) {\n lastKey = \"\"\n return\n }\n cells = buildFromImageData(data, cols, rows, snapshot)\n gridRows = rows\n setPhase(\"logo\")\n cursor.x = -999\n cursor.y = -999\n }\n\n const cyclePhase = () => {\n const p = options\n if (!p.interactive || reduce || cells.length === 0) return\n if (phase === \"logo\") {\n const range = Math.max(0, p.scatterRange)\n for (const cell of cells) {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.scatterX = (Math.random() * 2 - 1) * range\n cell.scatterY = Math.min((Math.random() * 2 - 1) * range, floor * 0.72)\n cell.fallSpeed = 0\n }\n staggerCells(cells, p.staggerFrames)\n setPhase(\"scattered\")\n return\n }\n if (phase === \"scattered\") {\n for (const cell of cells) cell.fallSpeed = 0\n setPhase(\"fallen\")\n return\n }\n if (phase === \"fallen\") {\n staggerCells(cells, p.staggerFrames)\n setPhase(\"returning\")\n }\n }\n\n const update = (frames: number) => {\n const p = options\n const reduced = reduce\n let everyoneHome = phase === \"returning\"\n\n for (const cell of cells) {\n if (cell.wait > 0) {\n cell.wait -= frames\n if (phase === \"returning\") everyoneHome = false\n continue\n }\n\n if (reduced || !p.interactive) {\n cell.offsetX = 0\n cell.offsetY = 0\n continue\n }\n\n if (phase === \"scattered\") {\n easeToward(\n cell,\n cell.scatterX,\n cell.scatterY,\n frameEase(p.scatterEase, frames)\n )\n continue\n }\n\n if (phase === \"fallen\") {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.fallSpeed += p.gravity * frames\n cell.offsetY += cell.fallSpeed * frames\n if (cell.offsetY >= floor) {\n cell.offsetY = floor\n cell.fallSpeed *= -Math.min(0.95, Math.max(0, p.bounce))\n if (Math.abs(cell.fallSpeed) < 0.12) cell.fallSpeed = 0\n }\n continue\n }\n\n if (phase === \"returning\") {\n easeToward(cell, 0, 0, frameEase(p.resetEase, frames))\n if (Math.abs(cell.offsetX) > 0.04 || Math.abs(cell.offsetY) > 0.04) {\n everyoneHome = false\n }\n continue\n }\n\n const dx = cell.col - cursor.x\n const dy = cell.row - cursor.y\n const dist = Math.hypot(dx, dy)\n const radius = Math.max(0.01, p.hoverRadius)\n if (dist < radius) {\n const push = (1 - dist / radius) * p.hoverPush\n if (dist < 0.0001) {\n easeToward(cell, push, 0, frameEase(p.hoverEase, frames))\n } else {\n easeToward(\n cell,\n (dx / dist) * push,\n (dy / dist) * push,\n frameEase(p.hoverEase, frames)\n )\n }\n if (Math.random() < 0.06 * frames) {\n cell.char = pickChar(p.charset)\n }\n } else {\n easeToward(cell, 0, 0, frameEase(p.hoverEase, frames))\n }\n }\n\n if (everyoneHome) setPhase(\"logo\")\n }\n\n const draw = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n const dark = resolveDark(p.theme)\n const ink = p.color ?? (dark ? DARK.ink : LIGHT.ink)\n const paper = p.backgroundColor ?? (dark ? DARK.paper : LIGHT.paper)\n const step = Math.max(4, p.cellSize + p.cellGap)\n\n if (paper === \"transparent\") {\n ctx.clearRect(0, 0, w, h)\n } else {\n ctx.fillStyle = paper\n ctx.fillRect(0, 0, w, h)\n }\n\n ctx.font = `${Math.max(6, p.cellSize)}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.fillStyle = ink\n\n for (const cell of cells) {\n const x = (cell.col + cell.offsetX) * step + step * 0.5\n const y = (cell.row + cell.offsetY) * step + step * 0.5\n ctx.fillText(cell.char, x, y)\n }\n }\n\n const tick = (now: number) => {\n if (!running) return\n const dt = Math.min((now - last) / 1000, 0.05)\n last = now\n const frames = dt * 60\n if (gridKey() !== lastKey) void rebuild()\n update(reduce ? 0 : frames)\n draw()\n raf = requestAnimationFrame(tick)\n }\n\n const onPointerMove = (event: PointerEvent) => {\n if (!options.interactive) return\n const rect = root.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const step = Math.max(4, options.cellSize + options.cellGap)\n const x = (event.clientX - rect.left) / step\n const y = (event.clientY - rect.top) / step\n const inside =\n event.clientX >= rect.left &&\n event.clientX <= rect.right &&\n event.clientY >= rect.top &&\n event.clientY <= rect.bottom\n if (inside) {\n cursor.x = x\n cursor.y = y\n } else {\n cursor.x = -999\n cursor.y = -999\n }\n }\n\n const onPointerLeave = () => {\n cursor.x = -999\n cursor.y = -999\n }\n\n const onPointerDown = (event: PointerEvent) => {\n if (event.button !== 0) return\n cyclePhase()\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== \"Enter\" && event.key !== \" \") return\n event.preventDefault()\n cyclePhase()\n }\n\n void rebuild()\n raf = requestAnimationFrame(tick)\n\n const ro = new ResizeObserver(() => {\n lastW = -1\n lastH = -1\n lastKey = \"\"\n void rebuild()\n })\n ro.observe(root)\n\n window.addEventListener(\"pointermove\", onPointerMove, { passive: true })\n root.addEventListener(\"pointerleave\", onPointerLeave, { passive: true })\n root.addEventListener(\"pointerdown\", onPointerDown)\n root.addEventListener(\"keydown\", onKeyDown)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n },\n destroy() {\n running = false\n loadId += 1\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n window.removeEventListener(\"pointermove\", onPointerMove)\n root.removeEventListener(\"pointerleave\", onPointerLeave)\n root.removeEventListener(\"pointerdown\", onPointerDown)\n root.removeEventListener(\"keydown\", onKeyDown)\n },\n }\n}\n\nexport type AsciiLogoProps = AsciiLogoOptions & {\n className?: string\n /** Accessible name. Default is `text` or `\"ASCII logo\"`. */\n label?: string\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function AsciiLogo({\n className,\n text = \"23rd\",\n src,\n fit = 0.82,\n cellSize = 11,\n cellGap = 2,\n charset = DEFAULT_CHARSET,\n threshold = 0.2,\n invert,\n color,\n backgroundColor,\n hoverRadius = 7,\n hoverPush = 2.6,\n hoverEase = 0.18,\n scatterRange = 16,\n scatterEase = 0.055,\n gravity = 0.14,\n bounce = 0.28,\n resetEase = 0.08,\n staggerFrames = 18,\n interactive = true,\n theme = \"auto\",\n label,\n onPhaseChange,\n}: AsciiLogoProps) {\n const rootRef = useRef(null)\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const root = rootRef.current\n const canvas = canvasRef.current\n if (!root || !canvas) return\n instanceRef.current = createAsciiLogo(root, canvas, {\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n }, [\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n ])\n\n const aria = label ?? (src ? \"ASCII logo\" : text)\n\n return (\n \n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type AsciiLogoPhase = \"logo\" | \"scattered\" | \"fallen\" | \"returning\"\n\nexport type AsciiLogoTheme = \"light\" | \"dark\" | \"auto\"\n\n/** Wordmark `text` is capped at this many characters. */\nexport const MAX_TEXT_LETTERS = 5\n\n/** Keep at most `maxLetters` characters; extra input is dropped. */\nexport function clampAsciiLogoText(\n text: string,\n maxLetters = MAX_TEXT_LETTERS\n): string {\n return [...text].slice(0, maxLetters).join(\"\")\n}\n\nexport type AsciiLogoOptions = {\n /**\n * Wordmark sampled into the ASCII grid. Ignored when `src` is set.\n * Capped at `MAX_TEXT_LETTERS` (5). Default `\"23rd\"`\n */\n text?: string\n /** Image URL to sample instead of `text` (any raster or same-origin SVG). */\n src?: string\n /**\n * How much of the stage the source covers (0–1). Default `0.82`\n */\n fit?: number\n /** Glyph cell size in CSS pixels. Default `11` */\n cellSize?: number\n /** Gap between cells in CSS pixels. Default `2` */\n cellGap?: number\n /** Pool of glyphs. One is picked at random per cell. */\n charset?: string\n /**\n * Brightness (0–1) a sample must clear to become a glyph.\n * Default `0.2`\n */\n threshold?: number\n /**\n * Treat dark pixels as solid. Default `true` when `src` is set,\n * `false` for text (white ink on a black sampler).\n */\n invert?: boolean\n /** Glyph color (hex). Default follows theme. */\n color?: string\n /** Stage color (hex). Pass `\"transparent\"` to skip the fill. */\n backgroundColor?: string\n /** Cursor repulsion radius in grid cells. Default `7` */\n hoverRadius?: number\n /** How far glyphs shove away from the cursor. Default `2.6` */\n hoverPush?: number\n /** Hover ease (0–1). Default `0.18` */\n hoverEase?: number\n /** Max scatter offset in grid cells. Default `16` */\n scatterRange?: number\n /** Scatter ease (0–1). Default `0.055` */\n scatterEase?: number\n /** Fall acceleration in cells / frame @ 60fps. Default `0.14` */\n gravity?: number\n /** Bounce restitution (0–1). Default `0.28` */\n bounce?: number\n /** Reassemble ease (0–1). Default `0.08` */\n resetEase?: number\n /** Max frames a glyph waits before moving. Default `18` */\n staggerFrames?: number\n /** Pointer hover + click cycle. Default `true` */\n interactive?: boolean\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: AsciiLogoTheme\n /** Fires after each phase change (including the auto-return to `logo`). */\n onPhaseChange?: (phase: AsciiLogoPhase) => void\n}\n\nexport type AsciiLogoInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\ntype AsciiCell = {\n col: number\n row: number\n char: string\n offsetX: number\n offsetY: number\n scatterX: number\n scatterY: number\n fallSpeed: number\n wait: number\n}\n\nexport const DEFAULT_CHARSET =\n \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%&*\"\n\nconst LIGHT = { ink: \"#3f3f46\", paper: \"#fafafa\" }\nconst DARK = { ink: \"#a1a1aa\", paper: \"#09090b\" }\n\nconst TEXT_FONT =\n '900 1px \"Arial Black\", Impact, Arial, ui-sans-serif, system-ui, sans-serif'\n\nfunction isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nfunction resolveDark(theme: AsciiLogoTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nfunction pickChar(charset: string) {\n const pool = charset.length > 0 ? charset : DEFAULT_CHARSET\n return pool[Math.floor(Math.random() * pool.length)] ?? \"#\"\n}\n\nfunction loadImage(src: string): Promise {\n return new Promise((resolve, reject) => {\n const img = new Image()\n img.decoding = \"async\"\n if (/^https?:/i.test(src) && !src.startsWith(window.location.origin)) {\n img.crossOrigin = \"anonymous\"\n }\n img.onload = () => resolve(img)\n img.onerror = () => reject(new Error(\"AsciiLogo: failed to load image\"))\n img.src = src\n })\n}\n\nfunction easeToward(\n cell: AsciiCell,\n targetX: number,\n targetY: number,\n ease: number\n) {\n cell.offsetX += (targetX - cell.offsetX) * ease\n cell.offsetY += (targetY - cell.offsetY) * ease\n}\n\nfunction frameEase(ease: number, frames: number) {\n const e = Math.min(1, Math.max(0, ease))\n if (frames <= 0) return e\n return 1 - Math.pow(1 - e, frames)\n}\n\nfunction staggerCells(cells: AsciiCell[], staggerFrames: number) {\n const max = Math.max(0, staggerFrames)\n for (const cell of cells) {\n cell.wait = Math.random() * max\n }\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function createAsciiLogo(\n root: HTMLElement,\n canvas: HTMLCanvasElement,\n initial: AsciiLogoOptions = {}\n): AsciiLogoInstance | null {\n let options: Required<\n Pick<\n AsciiLogoOptions,\n | \"text\"\n | \"fit\"\n | \"cellSize\"\n | \"cellGap\"\n | \"charset\"\n | \"threshold\"\n | \"hoverRadius\"\n | \"hoverPush\"\n | \"hoverEase\"\n | \"scatterRange\"\n | \"scatterEase\"\n | \"gravity\"\n | \"bounce\"\n | \"resetEase\"\n | \"staggerFrames\"\n | \"interactive\"\n | \"theme\"\n >\n > &\n AsciiLogoOptions = {\n text: \"23rd\",\n fit: 0.82,\n cellSize: 11,\n cellGap: 2,\n charset: DEFAULT_CHARSET,\n threshold: 0.2,\n hoverRadius: 7,\n hoverPush: 2.6,\n hoverEase: 0.18,\n scatterRange: 16,\n scatterEase: 0.055,\n gravity: 0.14,\n bounce: 0.28,\n resetEase: 0.08,\n staggerFrames: 18,\n interactive: true,\n theme: \"auto\",\n ...initial,\n }\n options.text = clampAsciiLogoText(options.text)\n\n const ctx = canvas.getContext(\"2d\")\n if (!ctx) return null\n\n let raf = 0\n let running = true\n let last = performance.now()\n let gridRows = 0\n let cells: AsciiCell[] = []\n let phase: AsciiLogoPhase = \"logo\"\n let lastKey = \"\"\n let lastW = -1\n let lastH = -1\n let lastDpr = -1\n let loadId = 0\n let reduce = false\n const cursor = { x: -999, y: -999 }\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const setPhase = (next: AsciiLogoPhase) => {\n if (phase === next) return\n phase = next\n options.onPhaseChange?.(next)\n }\n\n const sizeCanvas = () => {\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return { w: 0, h: 0 }\n if (w === lastW && h === lastH && dpr === lastDpr) return { w, h }\n lastW = w\n lastH = h\n lastDpr = dpr\n canvas.width = Math.max(1, Math.floor(w * dpr))\n canvas.height = Math.max(1, Math.floor(h * dpr))\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n return { w, h }\n }\n\n const sampleSource = (\n sampler: CanvasRenderingContext2D,\n cols: number,\n rows: number,\n image: HTMLImageElement | null,\n p: typeof options\n ) => {\n sampler.fillStyle = \"#000\"\n sampler.fillRect(0, 0, cols, rows)\n\n const cover = Math.min(1, Math.max(0.2, p.fit))\n if (image && image.width > 0 && image.height > 0) {\n const maxW = cols * cover\n const maxH = rows * cover\n const scale = Math.min(maxW / image.width, maxH / image.height)\n const dw = image.width * scale\n const dh = image.height * scale\n sampler.drawImage(image, (cols - dw) / 2, (rows - dh) / 2, dw, dh)\n return\n }\n\n const word = p.text.trim()\n if (!word) return\n sampler.fillStyle = \"#fff\"\n sampler.textAlign = \"center\"\n sampler.textBaseline = \"middle\"\n let fontSize = rows * 0.52\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n const width = sampler.measureText(word).width\n const maxW = cols * cover\n if (width > maxW && width > 0) {\n fontSize *= maxW / width\n sampler.font = TEXT_FONT.replace(\"1px\", `${fontSize}px`)\n }\n sampler.fillText(word, cols / 2, rows / 2 + fontSize * 0.04)\n }\n\n const buildFromImageData = (\n data: ImageData,\n cols: number,\n rows: number,\n p: typeof options\n ) => {\n const shouldInvert = p.invert ?? Boolean(p.src)\n const lit = new Set()\n const pixels = data.data\n for (let row = 0; row < rows; row++) {\n for (let col = 0; col < cols; col++) {\n const i = (row * cols + col) * 4\n const r = pixels[i] ?? 0\n const g = pixels[i + 1] ?? 0\n const b = pixels[i + 2] ?? 0\n const a = (pixels[i + 3] ?? 0) / 255\n const luma = (r * 0.299 + g * 0.587 + b * 0.114) / 255\n const value = (shouldInvert ? 1 - luma : luma) * a\n if (value < p.threshold) continue\n lit.add(`${col},${row}`)\n if (col + 1 < cols) lit.add(`${col + 1},${row}`)\n }\n }\n\n const next: AsciiCell[] = []\n for (const key of lit) {\n const [colStr, rowStr] = key.split(\",\")\n const col = Number(colStr)\n const row = Number(rowStr)\n next.push({\n col,\n row,\n char: pickChar(p.charset),\n offsetX: 0,\n offsetY: 0,\n scatterX: 0,\n scatterY: 0,\n fallSpeed: 0,\n wait: 0,\n })\n }\n return next\n }\n\n const gridKey = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n if (w <= 0 || h <= 0) return \"\"\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n return [\n p.src ?? \"\",\n p.text,\n p.fit,\n p.cellSize,\n p.cellGap,\n p.charset,\n p.threshold,\n String(p.invert ?? \"\"),\n cols,\n rows,\n ].join(\"|\")\n }\n\n const rebuild = async () => {\n const p = options\n const { w, h } = sizeCanvas()\n if (w <= 0 || h <= 0) return\n const step = Math.max(4, p.cellSize + p.cellGap)\n const cols = Math.max(1, Math.floor(w / step))\n const rows = Math.max(1, Math.floor(h / step))\n const key = gridKey()\n if (!key || key === lastKey) return\n lastKey = key\n const id = ++loadId\n\n let image: HTMLImageElement | null = null\n if (p.src) {\n try {\n image = await loadImage(p.src)\n } catch {\n image = null\n }\n if (id !== loadId || !running) return\n }\n\n const sampler = document.createElement(\"canvas\")\n sampler.width = cols\n sampler.height = rows\n const samplerCtx = sampler.getContext(\"2d\", { willReadFrequently: true })\n if (!samplerCtx) return\n const snapshot = {\n ...options,\n src: image ? options.src : undefined,\n }\n sampleSource(samplerCtx, cols, rows, image, snapshot)\n let data: ImageData | null = null\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n data = null\n }\n if (!data && image) {\n sampleSource(samplerCtx, cols, rows, null, {\n ...snapshot,\n src: undefined,\n })\n try {\n data = samplerCtx.getImageData(0, 0, cols, rows)\n } catch {\n lastKey = \"\"\n return\n }\n }\n if (!data) {\n lastKey = \"\"\n return\n }\n cells = buildFromImageData(data, cols, rows, snapshot)\n gridRows = rows\n setPhase(\"logo\")\n cursor.x = -999\n cursor.y = -999\n }\n\n const cyclePhase = () => {\n const p = options\n if (!p.interactive || reduce || cells.length === 0) return\n if (phase === \"logo\") {\n const range = Math.max(0, p.scatterRange)\n for (const cell of cells) {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.scatterX = (Math.random() * 2 - 1) * range\n cell.scatterY = Math.min((Math.random() * 2 - 1) * range, floor * 0.72)\n cell.fallSpeed = 0\n }\n staggerCells(cells, p.staggerFrames)\n setPhase(\"scattered\")\n return\n }\n if (phase === \"scattered\") {\n for (const cell of cells) cell.fallSpeed = 0\n setPhase(\"fallen\")\n return\n }\n if (phase === \"fallen\") {\n staggerCells(cells, p.staggerFrames)\n setPhase(\"returning\")\n }\n }\n\n const update = (frames: number) => {\n const p = options\n const reduced = reduce\n let everyoneHome = phase === \"returning\"\n\n for (const cell of cells) {\n if (cell.wait > 0) {\n cell.wait -= frames\n if (phase === \"returning\") everyoneHome = false\n continue\n }\n\n if (reduced || !p.interactive) {\n cell.offsetX = 0\n cell.offsetY = 0\n continue\n }\n\n if (phase === \"scattered\") {\n easeToward(\n cell,\n cell.scatterX,\n cell.scatterY,\n frameEase(p.scatterEase, frames)\n )\n continue\n }\n\n if (phase === \"fallen\") {\n const floor = Math.max(0, gridRows - 1 - cell.row)\n cell.fallSpeed += p.gravity * frames\n cell.offsetY += cell.fallSpeed * frames\n if (cell.offsetY >= floor) {\n cell.offsetY = floor\n cell.fallSpeed *= -Math.min(0.95, Math.max(0, p.bounce))\n if (Math.abs(cell.fallSpeed) < 0.12) cell.fallSpeed = 0\n }\n continue\n }\n\n if (phase === \"returning\") {\n easeToward(cell, 0, 0, frameEase(p.resetEase, frames))\n if (Math.abs(cell.offsetX) > 0.04 || Math.abs(cell.offsetY) > 0.04) {\n everyoneHome = false\n }\n continue\n }\n\n const dx = cell.col - cursor.x\n const dy = cell.row - cursor.y\n const dist = Math.hypot(dx, dy)\n const radius = Math.max(0.01, p.hoverRadius)\n if (dist < radius) {\n const push = (1 - dist / radius) * p.hoverPush\n if (dist < 0.0001) {\n easeToward(cell, push, 0, frameEase(p.hoverEase, frames))\n } else {\n easeToward(\n cell,\n (dx / dist) * push,\n (dy / dist) * push,\n frameEase(p.hoverEase, frames)\n )\n }\n if (Math.random() < 0.06 * frames) {\n cell.char = pickChar(p.charset)\n }\n } else {\n easeToward(cell, 0, 0, frameEase(p.hoverEase, frames))\n }\n }\n\n if (everyoneHome) setPhase(\"logo\")\n }\n\n const draw = () => {\n const p = options\n const w = root.clientWidth\n const h = root.clientHeight\n const dark = resolveDark(p.theme)\n const ink = p.color ?? (dark ? DARK.ink : LIGHT.ink)\n const paper = p.backgroundColor ?? (dark ? DARK.paper : LIGHT.paper)\n const step = Math.max(4, p.cellSize + p.cellGap)\n\n if (paper === \"transparent\") {\n ctx.clearRect(0, 0, w, h)\n } else {\n ctx.fillStyle = paper\n ctx.fillRect(0, 0, w, h)\n }\n\n ctx.font = `${Math.max(6, p.cellSize)}px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`\n ctx.textAlign = \"center\"\n ctx.textBaseline = \"middle\"\n ctx.fillStyle = ink\n\n for (const cell of cells) {\n const x = (cell.col + cell.offsetX) * step + step * 0.5\n const y = (cell.row + cell.offsetY) * step + step * 0.5\n ctx.fillText(cell.char, x, y)\n }\n }\n\n const tick = (now: number) => {\n if (!running) return\n const dt = Math.min((now - last) / 1000, 0.05)\n last = now\n const frames = dt * 60\n if (gridKey() !== lastKey) void rebuild()\n update(reduce ? 0 : frames)\n draw()\n raf = requestAnimationFrame(tick)\n }\n\n const onPointerMove = (event: PointerEvent) => {\n if (!options.interactive) return\n const rect = root.getBoundingClientRect()\n if (rect.width <= 0 || rect.height <= 0) return\n const step = Math.max(4, options.cellSize + options.cellGap)\n const x = (event.clientX - rect.left) / step\n const y = (event.clientY - rect.top) / step\n const inside =\n event.clientX >= rect.left &&\n event.clientX <= rect.right &&\n event.clientY >= rect.top &&\n event.clientY <= rect.bottom\n if (inside) {\n cursor.x = x\n cursor.y = y\n } else {\n cursor.x = -999\n cursor.y = -999\n }\n }\n\n const onPointerLeave = () => {\n cursor.x = -999\n cursor.y = -999\n }\n\n const onPointerDown = (event: PointerEvent) => {\n if (event.button !== 0) return\n cyclePhase()\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== \"Enter\" && event.key !== \" \") return\n event.preventDefault()\n cyclePhase()\n }\n\n void rebuild()\n raf = requestAnimationFrame(tick)\n\n const ro = new ResizeObserver(() => {\n lastW = -1\n lastH = -1\n lastKey = \"\"\n void rebuild()\n })\n ro.observe(root)\n\n window.addEventListener(\"pointermove\", onPointerMove, { passive: true })\n root.addEventListener(\"pointerleave\", onPointerLeave, { passive: true })\n root.addEventListener(\"pointerdown\", onPointerDown)\n root.addEventListener(\"keydown\", onKeyDown)\n\n return {\n setOptions(next) {\n options = { ...options, ...next }\n if (typeof next.text === \"string\") {\n options.text = clampAsciiLogoText(next.text)\n }\n },\n destroy() {\n running = false\n loadId += 1\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n window.removeEventListener(\"pointermove\", onPointerMove)\n root.removeEventListener(\"pointerleave\", onPointerLeave)\n root.removeEventListener(\"pointerdown\", onPointerDown)\n root.removeEventListener(\"keydown\", onKeyDown)\n },\n }\n}\n\nexport type AsciiLogoProps = AsciiLogoOptions & {\n className?: string\n /** Accessible name. Default is `text` or `\"ASCII logo\"`. */\n label?: string\n}\n\n/**\n * Interactive ASCII wordmark — glyphs shove away from the cursor, then\n * click-cycle through scatter, gravity drop, and reassemble. Zero deps.\n */\nexport function AsciiLogo({\n className,\n text = \"23rd\",\n src,\n fit = 0.82,\n cellSize = 11,\n cellGap = 2,\n charset = DEFAULT_CHARSET,\n threshold = 0.2,\n invert,\n color,\n backgroundColor,\n hoverRadius = 7,\n hoverPush = 2.6,\n hoverEase = 0.18,\n scatterRange = 16,\n scatterEase = 0.055,\n gravity = 0.14,\n bounce = 0.28,\n resetEase = 0.08,\n staggerFrames = 18,\n interactive = true,\n theme = \"auto\",\n label,\n onPhaseChange,\n}: AsciiLogoProps) {\n const rootRef = useRef(null)\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const root = rootRef.current\n const canvas = canvasRef.current\n if (!root || !canvas) return\n instanceRef.current = createAsciiLogo(root, canvas, {\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n })\n }, [\n text,\n src,\n fit,\n cellSize,\n cellGap,\n charset,\n threshold,\n invert,\n color,\n backgroundColor,\n hoverRadius,\n hoverPush,\n hoverEase,\n scatterRange,\n scatterEase,\n gravity,\n bounce,\n resetEase,\n staggerFrames,\n interactive,\n theme,\n onPhaseChange,\n ])\n\n const aria = label ?? (src ? \"ASCII logo\" : clampAsciiLogoText(text))\n\n return (\n \n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/ascii-logo.tsx" } diff --git a/registry/ascii-logo/ascii-logo-demo.tsx b/registry/ascii-logo/ascii-logo-demo.tsx index baa1add..df26174 100644 --- a/registry/ascii-logo/ascii-logo-demo.tsx +++ b/registry/ascii-logo/ascii-logo-demo.tsx @@ -15,6 +15,8 @@ import { useHydratedTheme } from "@/hooks/use-hydrated-theme" import { usePreviewProps } from "@/hooks/use-preview-props" import { AsciiLogo, + MAX_TEXT_LETTERS, + clampAsciiLogoText, type AsciiLogoPhase, } from "@/registry/ascii-logo/ascii-logo" @@ -160,22 +162,34 @@ export function AsciiLogoDemo() { {source === "text" ? ( -
-
) : null}