267 lines
6.9 KiB
TypeScript
267 lines
6.9 KiB
TypeScript
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<Props>(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 (
|
||
<Pressable
|
||
onPress={onSelect}
|
||
style={{
|
||
marginBottom: 12,
|
||
width: itemWidth,
|
||
height: cardHeight,
|
||
}}
|
||
>
|
||
<Block style={{ width: itemWidth, height: cardHeight + shadowMargin, position: 'relative' }}>
|
||
<Canvas style={{ width: itemWidth, height: cardHeight + shadowMargin, position: 'absolute' }}>
|
||
<Group clip={parallelogramPath}>
|
||
{isVisible && displayImage && (
|
||
<SkiaImage
|
||
image={displayImage}
|
||
x={0}
|
||
y={0}
|
||
width={itemWidth}
|
||
height={itemWidth}
|
||
fit="cover"
|
||
/>
|
||
)}
|
||
|
||
<LinearGradient
|
||
start={vec(0, itemWidth)}
|
||
end={vec(0, itemWidth * 0.6)}
|
||
colors={['rgba(0,0,0,0.8)', 'transparent']}
|
||
/>
|
||
|
||
<Rect
|
||
x={0}
|
||
y={itemWidth}
|
||
width={itemWidth}
|
||
height={labelHeight}
|
||
color={isSelected ? '#FFE500' : '#FFFFFF'}
|
||
/>
|
||
|
||
{!isSelected && (
|
||
<Rect
|
||
x={0}
|
||
y={itemWidth}
|
||
width={itemWidth}
|
||
height={1}
|
||
color="#323232"
|
||
/>
|
||
)}
|
||
|
||
{authorParagraph && (
|
||
<Paragraph
|
||
paragraph={authorParagraph}
|
||
x={8}
|
||
y={itemWidth + (labelHeight - 10) / 3}
|
||
width={60}
|
||
/>
|
||
)}
|
||
|
||
{likeParagraph && (
|
||
<Paragraph
|
||
paragraph={likeParagraph}
|
||
x={itemWidth - 32}
|
||
y={itemWidth + (labelHeight - 10) / 3}
|
||
width={40}
|
||
/>
|
||
)}
|
||
</Group>
|
||
|
||
{/* 先画阴影,再画描边,保证描边永远在最外层 */}
|
||
<ParallelogramShadow
|
||
height={cardHeight + shadowMargin}
|
||
baseHeight={cardHeight}
|
||
padding={padding}
|
||
skewOffset={skewOffset}
|
||
width={itemWidth}
|
||
/>
|
||
|
||
<Path
|
||
path={parallelogramPath}
|
||
style="stroke"
|
||
strokeWidth={3}
|
||
color={isSelected ? '#FFE500' : '#323232'}
|
||
/>
|
||
</Canvas>
|
||
</Block>
|
||
</Pressable>
|
||
)
|
||
})
|
||
|
||
export default ParallelogramGridItem |