import React, { memo, useMemo } from 'react' import { Pressable } from 'react-native' import { Canvas, Image as SkiaImage, Group, LinearGradient, Paragraph, Path, Rect, Skia, useAnimatedImageValue, useImage, useFonts, vec, } from '@shopify/react-native-skia' import { Block } from '@share/components' import ParallelogramShadow from './ParallelogramShadow' type MediaItem = { id: string url: string webpPreviewUrl?: string authorName?: string likeCount?: number title?: string } type Props = { item: MediaItem isSelected: boolean itemWidth: number isVisible: boolean onSelect: () => void /** 是否使用 WebP 格式,默认 true */ isWebP?: boolean /** 图片压缩宽度,默认 256 */ compressionWidth?: number /** 是否启用压缩,默认 false(如果 webpPreviewUrl 已存在,通常不需要再次压缩) */ isCompression?: boolean /** 错误占位图 URL 或本地资源 */ errorSource?: string | number /** 自定义缓存键,用于需要重定向的 URL */ cacheKey?: string } // Cloudflare 图片优化服务 const IMAGE_OPTIMIZATION_BASE = 'https://bowong.cc/cdn-cgi/image' const ParallelogramGridItem = memo(function ParallelogramGridItem({ item, isSelected, itemWidth, isVisible, onSelect, isWebP = true, compressionWidth = 256, isCompression = false, errorSource, cacheKey, }) { const isNetworkImage = (uri: string | number): boolean => { if (typeof uri !== 'string') return false return uri.startsWith('http://') || uri.startsWith('https://') } const getCompressionUrl = (url: string): string => { const format = isWebP ? 'webp' : 'jpg' return `${IMAGE_OPTIMIZATION_BASE}/width=${compressionWidth},quality=75,format=${format}/${url}` } // 优先级处理:webpPreviewUrl > url > errorSource const imageSource = useMemo(() => { if (item.webpPreviewUrl) { const source = item.webpPreviewUrl if (isNetworkImage(source) && isCompression) { return getCompressionUrl(source) } return source } if (item.url) { const source = item.url if (isNetworkImage(source) && isCompression) { return getCompressionUrl(source) } return source } return null }, [item.webpPreviewUrl, item.url, isCompression, isWebP, compressionWidth]) // 使用 useAnimatedImageValue 加载主图片(支持 GIF 和动画 WebP) const skiaImage = useAnimatedImageValue(imageSource || null) const errorImage = useImage(errorSource || null) // 最终使用的图片:主图片优先,失败时使用错误占位图 const displayImage = useMemo(() => { if (skiaImage) { return skiaImage } return errorImage || null }, [skiaImage, errorImage]) const labelHeight = 24 const cardHeight = itemWidth + labelHeight // 预留给外部阴影的额外画布高度,避免阴影被裁剪 const shadowMargin = 4 // 平行四边形路径(只裁剪内容,不拉伸内容) const skewOffset = 8 const padding = 2 // 四周内缩,避免描边被裁掉 // 外层(黑色)边框 & 裁剪路径 const parallelogramPath = useMemo(() => { const p = Skia.Path.Make() p.moveTo(skewOffset + padding, padding) p.lineTo(itemWidth - padding, padding) p.lineTo(itemWidth - skewOffset - padding, cardHeight - padding) p.lineTo(padding, cardHeight - padding) p.close() return p }, [cardHeight, itemWidth]) const fontMgr = useFonts({ System: [], }) const authorParagraph = useMemo(() => { if (!fontMgr) return null const builder = Skia.ParagraphBuilder.Make( { textAlign: 0, }, fontMgr, ) builder.pushStyle({ color: Skia.Color('#323232'), fontSize: 10, fontFamilies: ['System'], fontStyle: { weight: 400 }, }) builder.addText(item.authorName || '未知作者') const para = builder.build() para.layout(40) return para }, [fontMgr, item.authorName]) const likeParagraph = useMemo(() => { if (!fontMgr) return null const builder = Skia.ParagraphBuilder.Make( { textAlign: 0, }, fontMgr, ) builder.pushStyle({ color: Skia.Color('#FF0000'), fontSize: 8, fontFamilies: ['System'], fontStyle: { weight: 900 }, }) builder.addText('❤ ') builder.pushStyle({ color: Skia.Color('#323232'), fontSize: 10, fontFamilies: ['System'], fontStyle: { weight: 400 }, }) builder.addText(String(item.likeCount ?? 0)) const para = builder.build() para.layout(40) return para }, [fontMgr, item.likeCount]) return ( {isVisible && displayImage && ( )} {!isSelected && ( )} {authorParagraph && ( )} {likeParagraph && ( )} {/* 先画阴影,再画描边,保证描边永远在最外层 */} ) }) export default ParallelogramGridItem