From eba605fd90680e1c6f509ceeb24da72276a1c35e Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sat, 27 Dec 2025 22:47:27 +0800 Subject: [PATCH] fix(wallet-card): render hologram in single canvas --- src/components/wallet/refraction.tsx | 627 +++++++++++++++++++++ src/components/wallet/wallet-card.test.tsx | 66 ++- src/components/wallet/wallet-card.tsx | 233 ++------ src/hooks/useCardInteraction.ts | 20 +- 4 files changed, 735 insertions(+), 211 deletions(-) create mode 100644 src/components/wallet/refraction.tsx diff --git a/src/components/wallet/refraction.tsx b/src/components/wallet/refraction.tsx new file mode 100644 index 0000000..58b0d40 --- /dev/null +++ b/src/components/wallet/refraction.tsx @@ -0,0 +1,627 @@ +import { useEffect, useMemo, useRef } from 'react' + +type RenderMode = 'dynamic' | 'static' + +export interface HologramCanvasProps { + enabledPattern: boolean + enabledWatermark: boolean + mode?: RenderMode + /** + * 指针位置(-1~1) + * - x: 左(-1) → 右(1) + * - y: 上(-1) → 下(1) + */ + pointerX: number + pointerY: number + active: boolean + themeHue: number + /** + * 水印 mask(白色 + alpha),用于在 Canvas 内生成平铺 watermark。 + * 该 URL 通常来自 useMonochromeMask。 + */ + watermarkMaskUrl?: string | null + /** 水印单元格尺寸(CSS px) */ + watermarkCellSize?: number + /** 水印图标绘制尺寸(CSS px) */ + watermarkIconSize?: number +} + +const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)) + +function normalizeHueDegrees(value: number) { + const mod = value % 360 + return mod < 0 ? mod + 360 : mod +} + +function srgbEncode(linear: number) { + const v = clamp(linear, 0, 1) + if (v <= 0.0031308) return 12.92 * v + return 1.055 * Math.pow(v, 1 / 2.4) - 0.055 +} + +function oklchToSrgbCssColor(L: number, C: number, H: number) { + const hr = (normalizeHueDegrees(H) * Math.PI) / 180 + const a = C * Math.cos(hr) + const b = C * Math.sin(hr) + + // OKLab -> LMS (non-linear) + const l_ = L + 0.3963377774 * a + 0.2158037573 * b + const m_ = L - 0.1055613458 * a - 0.0638541728 * b + const s_ = L - 0.0894841775 * a - 1.291485548 * b + + const l = l_ * l_ * l_ + const m = m_ * m_ * m_ + const s = s_ * s_ * s_ + + // linear sRGB + const rLin = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s + const gLin = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s + const bLin = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s + + const r = clamp(Math.round(srgbEncode(rLin) * 255), 0, 255) + const g = clamp(Math.round(srgbEncode(gLin) * 255), 0, 255) + const bb = clamp(Math.round(srgbEncode(bLin) * 255), 0, 255) + + return `rgb(${r}, ${g}, ${bb})` +} + +// 纹理(mask)由业务方提供;这里使用 KeyApp 当前的三角纹理(等价于原 DOM mask)。 +const TRIANGLE_MASK_DATA_URL = + 'data:image/svg+xml,%3Csvg%20width%3D%2710%27%20height%3D%2710%27%20viewBox%3D%270%200%2010%2010%27%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%3E%3Cpath%20d%3D%27M0%2010%20L10%2010%20L10%200%20Z%27%20fill%3D%27black%27%2F%3E%3C%2Fsvg%3E' + +interface SizeInfo { + width: number + height: number + dpr: number + pxW: number + pxH: number +} + +function resizeCanvasToContainer(canvas: HTMLCanvasElement): SizeInfo { + const parent = canvas.parentElement + if (!parent) return { width: 0, height: 0, dpr: 1, pxW: 0, pxH: 0 } + + const rect = parent.getBoundingClientRect() + const dpr = typeof window === 'undefined' ? 1 : Math.max(1, Math.min(3, window.devicePixelRatio || 1)) + const width = Math.max(1, Math.round(rect.width)) + const height = Math.max(1, Math.round(rect.height)) + + // 重要:canvas 的 width/height attribute 是绘制缓冲区像素尺寸(device pixels), + // 但 CSS 布局尺寸必须保持为未缩放的 CSS px,否则高 DPR 设备会“看起来被放大”。 + // 仅依赖 absolute/inset 在部分 Android WebView 上不够稳定,这里显式写入。 + const cssW = `${width}px` + const cssH = `${height}px` + if (canvas.style.width !== cssW) canvas.style.width = cssW + if (canvas.style.height !== cssH) canvas.style.height = cssH + + const pxW = Math.max(1, Math.round(width * dpr)) + const pxH = Math.max(1, Math.round(height * dpr)) + + if (canvas.width !== pxW) canvas.width = pxW + if (canvas.height !== pxH) canvas.height = pxH + + return { width, height, dpr, pxW, pxH } +} + +interface BackgroundStops { + themeHue: number + c0: string + c1: string + c2: string +} + +function resolveBackgroundStops(themeHue: number): BackgroundStops | null { + // themeHue 是 OKLCH 的 hue(0~360)。 + // 这里直接做 OKLCH -> sRGB 转换,避免依赖浏览器对 oklch() 的 computed-style 输出格式差异。 + return { + themeHue, + c0: oklchToSrgbCssColor(0.5, 0.2, themeHue), + c1: oklchToSrgbCssColor(0.4, 0.22, themeHue + 20), + c2: oklchToSrgbCssColor(0.3, 0.18, themeHue + 40), + } +} + +function drawBackground(ctx: CanvasRenderingContext2D, w: number, h: number, bg: BackgroundStops | null) { + ctx.save() + ctx.globalCompositeOperation = 'source-over' + ctx.globalAlpha = 1 + ctx.filter = 'none' + + if (bg) { + const grad = ctx.createLinearGradient(0, 0, w, h) + grad.addColorStop(0, bg.c0) + grad.addColorStop(0.5, bg.c1) + grad.addColorStop(1, bg.c2) + ctx.fillStyle = grad + } else { + ctx.fillStyle = 'rgb(0, 0, 0)' + } + + ctx.fillRect(0, 0, w, h) + ctx.restore() +} + +function centeredPatternOffset(containerPx: number, tilePx: number) { + if (!(tilePx > 0)) return 0 + const halfTile = tilePx / 2 + const desired = containerPx / 2 - halfTile + const mod = ((desired % tilePx) + tilePx) % tilePx + return mod +} + +function fillWithCenteredPattern(ctx: CanvasRenderingContext2D, pattern: CanvasPattern, w: number, h: number, tilePx: number) { + const ox = centeredPatternOffset(w, tilePx) + const oy = centeredPatternOffset(h, tilePx) + + ctx.save() + ctx.translate(-ox, -oy) + ctx.fillStyle = pattern + // 多画一圈,避免偏移后边缘漏填 + ctx.fillRect(-tilePx, -tilePx, w + tilePx * 2, h + tilePx * 2) + ctx.restore() +} + +interface RefractionCornerConfig { + corner: 'bottom-left' | 'top-right' + sizeMultiplier: 5 + translateX: { base: number; xFactor: number; clamp: number } + translateY: { yFactor: number; mode: 'max0' | 'min0' } + scale: { base: number; xFactor: number } +} + +const REFRACTION_CORNERS: RefractionCornerConfig[] = [ + { + corner: 'bottom-left', + sizeMultiplier: 5, + translateX: { base: -0.1, xFactor: 0.1, clamp: 0.1 }, + translateY: { yFactor: -0.1, mode: 'max0' }, + scale: { base: 0.15, xFactor: 0.25 }, + }, + { + corner: 'top-right', + sizeMultiplier: 5, + translateX: { base: 0.1, xFactor: 0.1, clamp: 0.1 }, + translateY: { yFactor: -0.1, mode: 'min0' }, + scale: { base: 0.15, xFactor: -0.65 }, + }, +] + +function getDemoGradientStops() { + // DEMO:transparent 10% ... transparent 60%,中间 3 个颜色 stop 未指定位置 => 平均分布 + const start = 0.1 + const end = 0.6 + const step = (end - start) / 4 + + return [ + { stop: start, color: 'rgba(0,0,0,0)' }, + // hsl(5 100% 80%), hsl(150 100% 60%), hsl(220 90% 70%) + { stop: start + step * 1, color: 'rgb(255, 162, 153)' }, + { stop: start + step * 2, color: 'rgb(51, 255, 153)' }, + { stop: start + step * 3, color: 'rgb(110, 156, 247)' }, + { stop: end, color: 'rgba(0,0,0,0)' }, + ] as const +} + +function drawRefraction( + ctx: CanvasRenderingContext2D, + config: RefractionCornerConfig, + canvas: { w: number; h: number }, + pointer: { x: number; y: number }, + alpha: number, +) { + if (!(alpha > 0)) return + + // 复刻 DEMO:refraction 宽度 500%(按容器宽度),并保持正方形 + const elementSide = canvas.w * config.sizeMultiplier + // radial-gradient(circle ...) 默认 size = farthest-corner + const radius = elementSide * Math.SQRT2 + + const scale = Math.min(1, config.scale.base + pointer.x * config.scale.xFactor) + + const tx = + clamp(config.translateX.base + pointer.x * config.translateX.xFactor, -config.translateX.clamp, config.translateX.clamp) * + elementSide + + const rawTy = pointer.y * config.translateY.yFactor * elementSide + const ty = config.translateY.mode === 'max0' ? Math.max(0, rawTy) : Math.min(0, rawTy) + + let originX = 0 + let originY = 0 + let elementBoxX = 0 + let elementBoxY = 0 + + if (config.corner === 'bottom-left') { + originX = 0 + originY = canvas.h + // absolute bottom-0 left-0: box 从左下角向右上延伸 + elementBoxX = 0 + elementBoxY = -elementSide + } else { + originX = canvas.w + originY = 0 + // absolute top-0 right-0: box 从右上角向左下延伸 + elementBoxX = -elementSide + elementBoxY = 0 + } + + ctx.save() + ctx.globalAlpha = alpha + ctx.translate(originX, originY) + ctx.translate(tx, ty) + ctx.scale(scale, scale) + // DEMO:单个 refraction 元素 filter:saturate(2) + ctx.filter = 'saturate(2)' + + const grad = ctx.createRadialGradient(0, 0, 0, 0, 0, radius) + for (const s of getDemoGradientStops()) grad.addColorStop(s.stop, s.color) + + ctx.fillStyle = grad + ctx.fillRect(elementBoxX, elementBoxY, elementSide, elementSide) + ctx.restore() +} + +function ensureScaledPattern( + ctx: CanvasRenderingContext2D, + cache: { key: string; pattern: CanvasPattern; tilePx: number } | null, + key: string, + image: HTMLImageElement, + tilePx: number, +) { + if (cache && cache.key === key) return cache + + const tile = document.createElement('canvas') + tile.width = tilePx + tile.height = tilePx + const tctx = tile.getContext('2d') + if (!tctx) return null + + tctx.clearRect(0, 0, tile.width, tile.height) + tctx.drawImage(image, 0, 0, tile.width, tile.height) + + const pattern = ctx.createPattern(tile, 'repeat') + if (!pattern) return null + + return { key, pattern, tilePx } +} + +function ensureCenteredIconPattern( + ctx: CanvasRenderingContext2D, + cache: { key: string; pattern: CanvasPattern; tilePx: number } | null, + key: string, + image: HTMLImageElement, + cellPx: number, + iconPx: number, +) { + if (cache && cache.key === key) return cache + + const tile = document.createElement('canvas') + tile.width = cellPx + tile.height = cellPx + const tctx = tile.getContext('2d') + if (!tctx) return null + + tctx.clearRect(0, 0, tile.width, tile.height) + const offset = (cellPx - iconPx) / 2 + tctx.drawImage(image, offset, offset, iconPx, iconPx) + + const pattern = ctx.createPattern(tile, 'repeat') + if (!pattern) return null + + return { key, pattern, tilePx: cellPx } +} + +function renderMaskedLayer( + layerCtx: CanvasRenderingContext2D, + canvas: { w: number; h: number }, + pointer: { x: number; y: number }, + refractionAlpha: number, + baseFill: string, + mask: { pattern: CanvasPattern; tilePx: number }, +) { + layerCtx.save() + layerCtx.setTransform(1, 0, 0, 1, 0, 0) + layerCtx.clearRect(0, 0, canvas.w, canvas.h) + layerCtx.globalCompositeOperation = 'source-over' + layerCtx.globalAlpha = 1 + layerCtx.filter = 'none' + + // DEMO:pattern/watermark 自身有 ::before 的纯色底 + layerCtx.fillStyle = baseFill + layerCtx.fillRect(0, 0, canvas.w, canvas.h) + + for (const cfg of REFRACTION_CORNERS) { + drawRefraction(layerCtx, cfg, canvas, pointer, refractionAlpha) + } + + // 应用 mask(等价于 CSS mask) + layerCtx.globalCompositeOperation = 'destination-in' + layerCtx.globalAlpha = 1 + layerCtx.filter = 'none' + fillWithCenteredPattern(layerCtx, mask.pattern, canvas.w, canvas.h, mask.tilePx) + + layerCtx.restore() +} + +function drawSpotlight( + ctx: CanvasRenderingContext2D, + canvas: { w: number; h: number }, + pointer: { x: number; y: number }, + alpha: number, +) { + if (!(alpha > 0)) return + + // 对齐 DEMO:spotlight::before width=500%(基于 card 宽度),translate(-50% + pointer*20%) + const elementSide = canvas.w * 5 + const radius = (elementSide * Math.SQRT2) / 2 + + const cx = canvas.w / 2 + pointer.x * canvas.w + const cy = canvas.h / 2 + pointer.y * canvas.w + + ctx.save() + ctx.globalCompositeOperation = 'overlay' + ctx.globalAlpha = alpha + ctx.filter = 'brightness(1.2) contrast(1.2)' + + const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius) + grad.addColorStop(0, 'rgba(255,255,255,0.4)') + grad.addColorStop(0.02, 'rgba(255,255,255,0.4)') + grad.addColorStop(0.2, 'rgba(26,26,26,0.2)') + grad.addColorStop(1, 'rgba(26,26,26,0.2)') + + ctx.fillStyle = grad + ctx.fillRect(0, 0, canvas.w, canvas.h) + ctx.restore() +} + +export function HologramCanvas({ + enabledPattern, + enabledWatermark, + mode = 'dynamic', + pointerX, + pointerY, + active, + themeHue, + watermarkMaskUrl, + watermarkCellSize, + watermarkIconSize, +}: HologramCanvasProps) { + const canvasRef = useRef(null) + const scheduleRenderRef = useRef<(() => void) | null>(null) + + const stablePointer = useMemo(() => { + if (mode === 'static') return { x: 0, y: 0 } + return { x: clamp(pointerX, -1, 1), y: clamp(pointerY, -1, 1) } + }, [mode, pointerX, pointerY]) + + const stableActive = mode === 'static' ? false : active + const renderStateRef = useRef({ + enabledPattern, + enabledWatermark, + pointer: stablePointer, + active: stableActive, + watermarkMaskUrl, + watermarkCellSize, + watermarkIconSize, + }) + renderStateRef.current = { + enabledPattern, + enabledWatermark, + pointer: stablePointer, + active: stableActive, + watermarkMaskUrl, + watermarkCellSize, + watermarkIconSize, + } + + const bgStopsRef = useRef(null) + useEffect(() => { + bgStopsRef.current = resolveBackgroundStops(themeHue) + scheduleRenderRef.current?.() + }, [themeHue]) + + const triangleMaskImageRef = useRef(null) + useEffect(() => { + let canceled = false + const img = new Image() + img.onload = () => { + if (canceled) return + triangleMaskImageRef.current = img + scheduleRenderRef.current?.() + } + img.onerror = () => { + if (canceled) return + triangleMaskImageRef.current = null + scheduleRenderRef.current?.() + } + img.src = TRIANGLE_MASK_DATA_URL + return () => { + canceled = true + } + }, []) + + const watermarkMaskImageRef = useRef(null) + useEffect(() => { + if (!watermarkMaskUrl) { + watermarkMaskImageRef.current = null + scheduleRenderRef.current?.() + return + } + + let canceled = false + const img = new Image() + img.crossOrigin = 'anonymous' + img.onload = () => { + if (canceled) return + watermarkMaskImageRef.current = img + scheduleRenderRef.current?.() + } + img.onerror = () => { + if (canceled) return + watermarkMaskImageRef.current = null + scheduleRenderRef.current?.() + } + img.src = watermarkMaskUrl + return () => { + canceled = true + } + }, [watermarkMaskUrl]) + + const patternLayerRef = useRef(null) + const watermarkLayerRef = useRef(null) + + const patternMaskCacheRef = useRef<{ key: string; pattern: CanvasPattern; tilePx: number } | null>(null) + const watermarkMaskCacheRef = useRef<{ key: string; pattern: CanvasPattern; tilePx: number } | null>(null) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + const ctx = canvas.getContext('2d') + if (!ctx) return + + let rafId: number | null = null + + const render = () => { + const state = renderStateRef.current + const size = resizeCanvasToContainer(canvas) + if (size.pxW <= 0 || size.pxH <= 0) return + + const w = size.pxW + const h = size.pxH + + ctx.setTransform(1, 0, 0, 1, 0, 0) + ctx.clearRect(0, 0, canvas.width, canvas.height) + + drawBackground(ctx, w, h, bgStopsRef.current) + + const refractionAlpha = state.active ? 1 : 0 + + // DEMO:pattern layer => mix-blend-mode:multiply + opacity:0.4 + filter:saturate/contrast/brightness + if (state.enabledPattern) { + const maskImg = triangleMaskImageRef.current + if (maskImg) { + const layer = patternLayerRef.current ?? document.createElement('canvas') + if (patternLayerRef.current !== layer) patternLayerRef.current = layer + if (layer.width !== w) layer.width = w + if (layer.height !== h) layer.height = h + const layerCtx = layer.getContext('2d') + if (layerCtx) { + const tilePx = Math.max(1, Math.round(24 * size.dpr)) + const maskKey = `triangle:${tilePx}` + const nextCache = ensureScaledPattern(layerCtx, patternMaskCacheRef.current, maskKey, maskImg, tilePx) + if (nextCache) { + patternMaskCacheRef.current = nextCache + + renderMaskedLayer( + layerCtx, + { w, h }, + state.pointer, + refractionAlpha, + 'rgb(204, 204, 204)', + { pattern: nextCache.pattern, tilePx: nextCache.tilePx }, + ) + + ctx.save() + ctx.globalCompositeOperation = 'multiply' + ctx.globalAlpha = 0.4 + ctx.filter = 'saturate(0.8) contrast(1) brightness(1)' + ctx.drawImage(layer, 0, 0) + ctx.restore() + } + } + } + } + + // DEMO:watermark layer => mix-blend-mode:hard-light + opacity:1 + filter:saturate/contrast/brightness + if (state.enabledWatermark) { + const maskImg = watermarkMaskImageRef.current + const cellSize = state.watermarkCellSize ?? 40 + const iconSize = state.watermarkIconSize ?? 24 + + if (maskImg && cellSize > 0 && iconSize > 0) { + const layer = watermarkLayerRef.current ?? document.createElement('canvas') + if (watermarkLayerRef.current !== layer) watermarkLayerRef.current = layer + if (layer.width !== w) layer.width = w + if (layer.height !== h) layer.height = h + const layerCtx = layer.getContext('2d') + if (layerCtx) { + const cellPx = Math.max(1, Math.round(cellSize * size.dpr)) + const iconPx = Math.max(1, Math.round(iconSize * size.dpr)) + const maskKey = `cell:${state.watermarkMaskUrl ?? 'null'}:${cellPx}:${iconPx}` + const nextCache = ensureCenteredIconPattern(layerCtx, watermarkMaskCacheRef.current, maskKey, maskImg, cellPx, iconPx) + if (nextCache) { + watermarkMaskCacheRef.current = nextCache + + renderMaskedLayer(layerCtx, { w, h }, state.pointer, refractionAlpha, 'rgba(255, 255, 255, 0.2)', { + pattern: nextCache.pattern, + tilePx: nextCache.tilePx, + }) + + ctx.save() + ctx.globalCompositeOperation = 'hard-light' + ctx.globalAlpha = 1 + ctx.filter = 'saturate(0.9) contrast(1.1) brightness(1.2)' + ctx.drawImage(layer, 0, 0) + ctx.restore() + } + } + } + } + + // DEMO:spotlight => mix-blend-mode: overlay(hover/tap 时出现) + drawSpotlight(ctx, { w, h }, state.pointer, state.active ? 1 : 0) + } + + const schedule = () => { + if (rafId != null) return + rafId = window.requestAnimationFrame(() => { + rafId = null + render() + }) + } + + scheduleRenderRef.current = schedule + + const parent = canvas.parentElement + const ro = + typeof ResizeObserver !== 'undefined' && parent + ? new ResizeObserver(() => { + schedule() + }) + : null + + ro?.observe(parent!) + schedule() + + return () => { + scheduleRenderRef.current = null + if (rafId != null) window.cancelAnimationFrame(rafId) + ro?.disconnect() + } + }, []) + + // props 变化时触发渲染(避免 effect 里创建新的 ResizeObserver) + useEffect(() => { + scheduleRenderRef.current?.() + }, [ + enabledPattern, + enabledWatermark, + stableActive, + stablePointer, + themeHue, + watermarkMaskUrl, + watermarkCellSize, + watermarkIconSize, + ]) + + return ( + + ) +} diff --git a/src/components/wallet/wallet-card.test.tsx b/src/components/wallet/wallet-card.test.tsx index 635a9fa..51618b7 100644 --- a/src/components/wallet/wallet-card.test.tsx +++ b/src/components/wallet/wallet-card.test.tsx @@ -4,6 +4,17 @@ import userEvent from '@testing-library/user-event' import { WalletCard } from './wallet-card' import type { Wallet } from '@/stores' +vi.mock('./refraction', () => ({ + HologramCanvas: (props: { enabledPattern: boolean; enabledWatermark: boolean; mode?: string }) => ( +
+ ), +})) + // Mock useCardInteraction hook vi.mock('@/hooks/useCardInteraction', () => ({ useCardInteraction: () => ({ @@ -46,6 +57,23 @@ describe('WalletCard (3D)', () => { address: '0x1234567890abcdef1234567890abcdef12345678', } + const originalMatchMedia = window.matchMedia + const setReducedMotion = (matches: boolean) => { + Object.defineProperty(window, 'matchMedia', { + value: (query: string) => ({ + matches: query === '(prefers-reduced-motion: reduce)' ? matches : false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), + configurable: true, + }) + } + const originalUserAgent = navigator.userAgent const setUserAgent = (ua: string) => { Object.defineProperty(navigator, 'userAgent', { value: ua, configurable: true }) @@ -183,25 +211,47 @@ describe('WalletCard (3D)', () => { it('renders watermark refractions on non-Android when chainIconUrl provided', () => { setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36') + setReducedMotion(false) render() - expect(screen.getByTestId('wallet-card-refraction-watermark-1')).toBeInTheDocument() - expect(screen.getByTestId('wallet-card-refraction-watermark-2')).toBeInTheDocument() + expect(screen.getByTestId('wallet-card-hologram-canvas')).toHaveAttribute('data-watermark-enabled', 'true') + expect(screen.getByTestId('wallet-card-hologram-canvas')).toHaveAttribute('data-mode', 'dynamic') + Object.defineProperty(window, 'matchMedia', { value: originalMatchMedia, configurable: true }) setUserAgent(originalUserAgent) }) - it('disables refraction layers by default on Android to avoid flicker', () => { - setUserAgent('Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/120.0.0.0 Mobile Safari/537.36') + it('disables refraction layers when prefers-reduced-motion is enabled', () => { + setReducedMotion(true) render() - expect(screen.queryByTestId('wallet-card-refraction-pattern-1')).not.toBeInTheDocument() - expect(screen.queryByTestId('wallet-card-refraction-pattern-2')).not.toBeInTheDocument() - expect(screen.queryByTestId('wallet-card-refraction-watermark-1')).not.toBeInTheDocument() - expect(screen.queryByTestId('wallet-card-refraction-watermark-2')).not.toBeInTheDocument() + expect(screen.getByTestId('wallet-card-hologram-canvas')).toHaveAttribute('data-pattern-enabled', 'false') + expect(screen.getByTestId('wallet-card-hologram-canvas')).toHaveAttribute('data-watermark-enabled', 'false') + expect(screen.getByTestId('wallet-card-hologram-canvas')).toHaveAttribute('data-mode', 'static') + + Object.defineProperty(window, 'matchMedia', { value: originalMatchMedia, configurable: true }) + setUserAgent(originalUserAgent) + }) + + it('allows enabling pattern refraction only (Android) for fine-grained experiments', () => { + setUserAgent('Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/120.0.0.0 Mobile Safari/537.36') + setReducedMotion(false) + + render( + , + ) + + expect(screen.getByTestId('wallet-card-hologram-canvas')).toHaveAttribute('data-pattern-enabled', 'true') + expect(screen.getByTestId('wallet-card-hologram-canvas')).toHaveAttribute('data-watermark-enabled', 'false') + Object.defineProperty(window, 'matchMedia', { value: originalMatchMedia, configurable: true }) setUserAgent(originalUserAgent) }) }) diff --git a/src/components/wallet/wallet-card.tsx b/src/components/wallet/wallet-card.tsx index 779416f..f3f68f3 100644 --- a/src/components/wallet/wallet-card.tsx +++ b/src/components/wallet/wallet-card.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useCallback, useEffect, useRef, useState, useMemo } from 'react'; +import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'; // 注册 CSS 自定义属性,使其可动画 // 只需注册一次,放在模块顶层 @@ -25,6 +25,7 @@ import { useCardInteraction } from '@/hooks/useCardInteraction'; import { useMonochromeMask } from '@/hooks/useMonochromeMask'; import { ChainIcon } from './chain-icon'; import { AddressDisplay } from './address-display'; +import { HologramCanvas } from './refraction'; import type { Wallet, ChainType } from '@/stores'; import { IconCopy as Copy, @@ -50,10 +51,17 @@ export interface WalletCardProps { className?: string | undefined; themeHue?: number | undefined; /** - * 禁用“折射 Refraction”层(Android 部分机型/浏览器组合会出现页面闪动)。 + * 禁用“水印 Refraction”层(Android 部分机型/浏览器组合会出现页面闪动)。 * 默认:在 Android 浏览器上自动禁用,其它平台启用。 */ - disableRefraction?: boolean | undefined; + disableWatermarkRefraction?: boolean | undefined; + /** + * 禁用“三角纹理 Refraction”层。 + * 默认:在 Android 浏览器上自动禁用,其它平台启用。 + * + * 说明:用户反馈 Pattern 在 Android 表现正常时,可以将该值显式设为 false 以保留效果。 + */ + disablePatternRefraction?: boolean | undefined; /** * 是否启用陀螺仪倾斜(deviceorientation)。 * 默认:在 Android 上关闭(降低高频重绘导致的闪动/掉帧风险)。 @@ -61,9 +69,6 @@ export interface WalletCardProps { enableGyro?: boolean | undefined; } -// 静态样式常量 - 避免每次渲染创建新对象 -const TRIANGLE_MASK_SVG = `url("data:image/svg+xml,%3Csvg width='10' height='10' viewBox='0 0 10 10' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 10 L10 10 L10 0 Z' fill='black'/%3E%3C/svg%3E")`; - export const WalletCard = forwardRef(function WalletCard( { wallet, @@ -78,7 +83,8 @@ export const WalletCard = forwardRef(function W onOpenSettings, className, themeHue = 323, - disableRefraction, + disableWatermarkRefraction, + disablePatternRefraction, enableGyro, }, ref, @@ -86,14 +92,16 @@ export const WalletCard = forwardRef(function W const [copied, setCopied] = useState(false); const cardRef = useRef(null); - const isAndroid = useMemo(() => { - if (typeof navigator === 'undefined') return false; - return /Android/i.test(navigator.userAgent); + const prefersReducedMotion = useMemo(() => { + if (typeof window === 'undefined') return false; + return window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false; }, []); - // Android 上:Refraction + mask + mix-blend-mode + 高频倾斜更新,容易触发浏览器合成层闪动(看起来像整页抖/闪)。 - const disableRefractionEffective = disableRefraction ?? isAndroid; - const enableGyroEffective = enableGyro ?? !isAndroid; + const disableWatermarkRefractionEffective = disableWatermarkRefraction ?? prefersReducedMotion; + const disablePatternRefractionEffective = disablePatternRefraction ?? prefersReducedMotion; + const enableGyroEffective = enableGyro ?? !prefersReducedMotion; + + const refractionMode = prefersReducedMotion ? ('static' as const) : ('dynamic' as const); // 将链图标转为单色遮罩(黑白 -> 透明) const monoMaskUrl = useMonochromeMask(chainIconUrl, { @@ -103,7 +111,8 @@ export const WalletCard = forwardRef(function W }); const { pointerX, pointerY, isActive, bindElement } = useCardInteraction({ - gyroStrength: 0.15, + // 提高重力感应对光影的影响(之前偏弱,手机端不明显) + gyroStrength: 0.35, touchStrength: 0.8, enableGyro: enableGyroEffective, }); @@ -151,33 +160,6 @@ export const WalletCard = forwardRef(function W // 动画配置 - 统一用于 transform 和光影 const transitionConfig = 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)'; // ease-out-back 回弹效果 - // 缓存背景渐变样式(只依赖 themeHue) - const bgGradient = useMemo( - () => `linear-gradient(135deg, - oklch(0.50 0.20 ${themeHue}) 0%, - oklch(0.40 0.22 ${themeHue + 20}) 50%, - oklch(0.30 0.18 ${themeHue + 40}) 100%)`, - [themeHue], - ); - - // 缓存 Logo mask 样式(只依赖 monoMaskUrl 和 watermarkLogoSize) - const logoMaskStyle = useMemo( - () => - monoMaskUrl - ? { - WebkitMaskImage: `url(${monoMaskUrl})`, - WebkitMaskSize: `${watermarkLogoSize}px ${watermarkLogoSize}px`, - WebkitMaskRepeat: 'repeat' as const, - WebkitMaskPosition: 'center', - maskImage: `url(${monoMaskUrl})`, - maskSize: `${watermarkLogoSize}px ${watermarkLogoSize}px`, - maskRepeat: 'repeat' as const, - maskPosition: 'center', - } - : null, - [monoMaskUrl, watermarkLogoSize], - ); - return (
{/* 卡片主体 - CSS 变量驱动所有动画 */} @@ -195,163 +177,18 @@ export const WalletCard = forwardRef(function W willChange: 'transform, --tilt-x, --tilt-y, --tilt-intensity', }} > - {/* 1. 主背景渐变 */} -
- - {/* 2. 防伪层1:三角纹理 (Pattern) + 双层折射 */} -
- {!disableRefractionEffective && ( - <> - {/* Refraction 1: 左下角 */} -
- {/* Refraction 2: 右上角 */} -
- - )} -
- - {/* 3. 防伪层2:Logo水印 (Watermark) + 双层折射 */} - {logoMaskStyle && ( -
- {!disableRefractionEffective && ( - <> - {/* Refraction 1: 左下角 */} -
- {/* Refraction 2: 右上角 */} -
- - )} -
- )} - - {/* 4. 表面高光 (Glare) - 基于物理反射 */} -
{/* 5. 边框装饰 */} diff --git a/src/hooks/useCardInteraction.ts b/src/hooks/useCardInteraction.ts index 38a8db5..aa7acb4 100644 --- a/src/hooks/useCardInteraction.ts +++ b/src/hooks/useCardInteraction.ts @@ -32,6 +32,13 @@ export function useCardInteraction(options: CardInteractionOptions = {}) { enableTouch = true, } = options + const applyGyroCurve = useCallback((value: number) => { + const abs = Math.abs(value) + // 让小幅度倾斜更“跟手”(小值放大,大值不变),提升手机端体感。 + const curved = Math.pow(abs, 0.7) + return Math.sign(value) * curved + }, []) + const elementRef = useRef(null) const [state, setState] = useState({ pointerX: 0, @@ -49,19 +56,22 @@ export function useCardInteraction(options: CardInteractionOptions = {}) { const gyro = gyroRef.current const touch = touchRef.current + const gyroX = applyGyroCurve(gyro.x) + const gyroY = applyGyroCurve(gyro.y) + let x = 0 let y = 0 let active = false if (touch.active && enableTouch) { // 触摸优先,但仍叠加轻微重力 - x = touch.x * touchStrength + gyro.x * gyroStrength * 0.3 - y = touch.y * touchStrength + gyro.y * gyroStrength * 0.3 + x = touch.x * touchStrength + gyroX * gyroStrength * 0.3 + y = touch.y * touchStrength + gyroY * gyroStrength * 0.3 active = true } else if (enableGyro && (Math.abs(gyro.x) > 0.01 || Math.abs(gyro.y) > 0.01)) { // 重力感应 - 有明显倾斜时也算激活 - x = gyro.x * gyroStrength - y = gyro.y * gyroStrength + x = gyroX * gyroStrength + y = gyroY * gyroStrength active = true } @@ -74,7 +84,7 @@ export function useCardInteraction(options: CardInteractionOptions = {}) { pointerY: y, isActive: active, }) - }, [gyroStrength, touchStrength, enableGyro, enableTouch]) + }, [applyGyroCurve, gyroStrength, touchStrength, enableGyro, enableTouch]) // 处理重力感应 useEffect(() => {