feat: 解析链接的 og 数据并添加到 preview 内容中 (#9752)

* feat: 解析链接的 og 数据并添加到 preview 内容中

* update test cases

* refactor(useMetaDataParser): 移除冗余的isLoaded状态并优化加载逻辑

* feat(hyperlink): 重构超链接组件,提取OG卡片为独立组件

将超链接预览功能中的OG卡片逻辑提取为独立的OGCard组件
简化Hyperlink组件逻辑,移除重复代码

* refactor(OGCard): 简化加载状态并改进骨架屏样式

移除多余的SkeletonContainer包装,直接在加载状态返回CardSkeleton
重构骨架屏组件,添加图片和文本骨架样式
调整容器布局为flex列布局并添加间距

* test(Hyperlink): 添加OGCard组件模拟并更新快照

---------

Co-authored-by: icarus <eurfelux@gmail.com>
This commit is contained in:
Konv Suu 2025-09-02 14:46:41 +08:00 committed by GitHub
parent 5ce7261678
commit 77c2255da4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 295 additions and 58 deletions

View File

@ -75,6 +75,7 @@
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
"@strongtz/win32-arm64-msvc": "^0.4.7",
"graceful-fs": "^4.2.11",
"htmlparser2": "^10.0.0",
"jsdom": "26.1.0",
"node-stream-zip": "^1.15.0",
"officeparser": "^4.2.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

View File

@ -0,0 +1,145 @@
import CherryLogo from '@renderer/assets/images/banner.png'
import Favicon from '@renderer/components/Icons/FallbackFavicon'
import { useMetaDataParser } from '@renderer/hooks/useMetaDataParser'
import { Skeleton, Typography } from 'antd'
import { useEffect, useMemo } from 'react'
import styled from 'styled-components'
const { Title, Paragraph } = Typography
type Props = {
link: string
show: boolean
}
export const OGCard = ({ link, show }: Props) => {
const openGraph = ['og:title', 'og:description', 'og:image', 'og:imageAlt'] as const
const { metadata, isLoading, parseMetadata } = useMetaDataParser(link, openGraph)
const hasImage = !!metadata['og:image']
const hostname = useMemo(() => {
try {
return new URL(link).hostname
} catch {
return null
}
}, [link])
useEffect(() => {
// use show to lazy loading
if (show && isLoading) {
parseMetadata()
}
}, [parseMetadata, isLoading, show])
if (isLoading) {
return <CardSkeleton />
}
return (
<PreviewContainer hasImage={hasImage}>
{hasImage && (
<PreviewImageContainer>
<PreviewImage src={metadata['og:image']} alt={metadata['og:imageAlt'] || link} />
</PreviewImageContainer>
)}
{!hasImage && (
<PreviewImageContainer>
<PreviewImage src={CherryLogo} alt={'no image'} />
</PreviewImageContainer>
)}
<PreviewContent>
<StyledHyperLink>
{hostname && <Favicon hostname={hostname} alt={link} />}
<Title
style={{
margin: 0,
fontSize: '14px',
lineHeight: '1.2',
color: 'var(--color-text)'
}}>
{metadata['og:title'] || hostname}
</Title>
</StyledHyperLink>
<Paragraph
title={metadata['og:description'] || link}
ellipsis={{ rows: 2 }}
style={{
fontSize: '12px',
lineHeight: '1.2',
color: 'var(--color-text-secondary)'
}}>
{metadata['og:description'] || link}
</Paragraph>
</PreviewContent>
</PreviewContainer>
)
}
const CardSkeleton = () => {
return (
<SkeletonContainer>
<Skeleton.Image style={{ width: '100%', height: 140 }} active />
<Skeleton
paragraph={{
rows: 1,
style: {
margin: '8px 0'
}
}}
active
/>
</SkeletonContainer>
)
}
const StyledHyperLink = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
const PreviewContainer = styled.div<{ hasImage?: boolean }>`
display: flex;
flex-direction: column;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 8px;
width: 380px;
height: 220px;
overflow: hidden;
`
const PreviewImageContainer = styled.div`
width: 100%;
height: 140px;
min-height: 140px;
overflow: hidden;
`
const PreviewContent = styled.div`
padding: 12px 16px;
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
`
const PreviewImage = styled.img`
width: 100%;
height: 140px;
object-fit: cover;
`
const SkeletonContainer = styled.div`
width: 380px;
height: 220px;
padding: 12px 16px;
display: flex;
flex-direction: column;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 8px;
gap: 16px;
`

View File

@ -0,0 +1,78 @@
import axios from 'axios'
import * as htmlparser2 from 'htmlparser2'
import { useCallback, useEffect, useRef, useState } from 'react'
export function useMetaDataParser<T extends string>(
link: string,
properties: readonly T[],
options?: {
timeout?: number
}
) {
const { timeout = 5000 } = options || {}
const [metadata, setMetadata] = useState<Record<T, string>>({} as Record<T, string>)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const abortControllerRef = useRef<AbortController | null>(null)
const parseMetadata = useCallback(async () => {
if (!link || !isLoading) return
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
const controller = new AbortController()
abortControllerRef.current = controller
setIsLoading(true)
setError(null)
try {
const response = await axios.get(link, { timeout, signal: controller.signal })
const htmlContent = response.data
const parsedMetadata = {} as Record<T, string>
const parser = new htmlparser2.Parser({
onopentag(tagName, attributes) {
if (tagName === 'meta') {
const { name: metaName, property: metaProperty, content } = attributes
const metaKey = metaName || metaProperty
if (!metaKey || !properties.includes(metaKey as T)) return
parsedMetadata[metaKey as T] = content
}
}
})
parser.parseComplete(htmlContent)
setMetadata(parsedMetadata)
} catch (err) {
// Don't set error if request was aborted
if (axios.isCancel(err) || (err instanceof Error && err.name === 'AbortError')) {
return
}
setError(err instanceof Error ? err : new Error('Failed to fetch HTML'))
} finally {
setIsLoading(false)
}
}, [isLoading, link, properties, timeout])
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
}
}, [])
return {
metadata,
isLoading,
error,
parseMetadata
}
}

View File

@ -1,13 +1,15 @@
import Favicon from '@renderer/components/Icons/FallbackFavicon'
import { OGCard } from '@renderer/components/OGCard'
import { Popover } from 'antd'
import React, { memo, useMemo } from 'react'
import styled from 'styled-components'
import React, { memo, useMemo, useState } from 'react'
interface HyperLinkProps {
children: React.ReactNode
href: string
}
const Hyperlink: React.FC<HyperLinkProps> = ({ children, href }) => {
const [open, setOpen] = useState(false)
const link = useMemo(() => {
try {
return decodeURIComponent(href)
@ -16,32 +18,20 @@ const Hyperlink: React.FC<HyperLinkProps> = ({ children, href }) => {
}
}, [href])
const hostname = useMemo(() => {
try {
return new URL(link).hostname
} catch {
return null
}
}, [link])
if (!href) return children
return (
<Popover
arrow={false}
content={
<StyledHyperLink>
{hostname && <Favicon hostname={hostname} alt={link} />}
<span>{link}</span>
</StyledHyperLink>
}
open={open}
onOpenChange={setOpen}
content={<OGCard link={link} show={open} />}
placement="top"
color="var(--color-background)"
styles={{
body: {
border: '1px solid var(--color-border)',
padding: '12px',
borderRadius: '8px'
padding: 0,
borderRadius: '8px',
overflow: 'hidden'
}
}}>
{children}
@ -49,17 +39,4 @@ const Hyperlink: React.FC<HyperLinkProps> = ({ children, href }) => {
)
}
const StyledHyperLink = styled.div`
color: var(--color-text);
display: flex;
align-items: center;
gap: 8px;
span {
max-width: min(400px, 70vw);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`
export default memo(Hyperlink)

View File

@ -18,17 +18,55 @@ const mocks = vi.hoisted(() => ({
),
Favicon: ({ hostname, alt }: { hostname: string; alt: string }) => (
<img data-testid="favicon" data-hostname={hostname} alt={alt} />
)
),
Typography: {
Title: ({ children }: { children: React.ReactNode }) => <div data-testid="title">{children}</div>,
Text: ({ children }: { children: React.ReactNode }) => <div data-testid="text">{children}</div>
},
Skeleton: () => <div data-testid="skeleton">Loading...</div>,
useMetaDataParser: vi.fn(() => ({
metadata: {},
isLoading: false,
isLoaded: true,
parseMetadata: vi.fn()
}))
}))
vi.mock('antd', () => ({
Popover: mocks.Popover
Popover: mocks.Popover,
Typography: mocks.Typography,
Skeleton: mocks.Skeleton
}))
vi.mock('@renderer/components/Icons/FallbackFavicon', () => ({
__esModule: true,
default: mocks.Favicon
}))
vi.mock('@renderer/hooks/useMetaDataParser', () => ({
useMetaDataParser: mocks.useMetaDataParser
}))
// Mock the OGCard component
vi.mock('@renderer/components/OGCard', () => ({
OGCard: ({ link }: { link: string; show: boolean }) => {
let hostname = ''
try {
hostname = new URL(link).hostname
} catch (e) {
// Ignore invalid URLs
}
return (
<div data-testid="og-card">
{hostname && <mocks.Favicon hostname={hostname} alt={link} />}
<mocks.Typography.Title>{hostname}</mocks.Typography.Title>
<mocks.Typography.Text>{link}</mocks.Typography.Text>
</div>
)
}
}))
describe('Hyperlink', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -69,7 +107,9 @@ describe('Hyperlink', () => {
// Content includes decoded url text and favicon with hostname
expect(screen.getByTestId('favicon')).toHaveAttribute('data-hostname', 'domain.com')
expect(screen.getByTestId('favicon')).toHaveAttribute('alt', 'https://domain.com/a b')
expect(screen.getByTestId('popover-content')).toHaveTextContent('https://domain.com/a b')
// The title should show hostname and text should show the full URL
expect(screen.getByTestId('title')).toHaveTextContent('domain.com')
expect(screen.getByTestId('text')).toHaveTextContent('https://domain.com/a b')
})
it('should not render favicon when URL parsing fails (invalid url)', () => {
@ -81,7 +121,9 @@ describe('Hyperlink', () => {
// decodeURIComponent succeeds => "not/url" is displayed
expect(screen.queryByTestId('favicon')).toBeNull()
expect(screen.getByTestId('popover-content')).toHaveTextContent('not/url')
// Since there's no hostname and no og:title, title shows empty, but text shows the URL
expect(screen.getByTestId('title')).toBeEmptyDOMElement()
expect(screen.getByTestId('text')).toHaveTextContent('not/url')
})
it('should not render favicon for non-http(s) scheme without hostname (mailto:)', () => {
@ -93,6 +135,8 @@ describe('Hyperlink', () => {
// Decoded to mailto:test@example.com, hostname is empty => no favicon
expect(screen.queryByTestId('favicon')).toBeNull()
expect(screen.getByTestId('popover-content')).toHaveTextContent('mailto:test@example.com')
// Since there's no hostname and no og:title, title shows empty, but text shows the decoded URL
expect(screen.getByTestId('title')).toBeEmptyDOMElement()
expect(screen.getByTestId('text')).toHaveTextContent('mailto:test@example.com')
})
})

View File

@ -1,42 +1,34 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Hyperlink > should match snapshot for normal url 1`] = `
.c0 {
color: var(--color-text);
display: flex;
align-items: center;
gap: 8px;
}
.c0 span {
max-width: min(400px,70vw);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
<div>
<div
data-arrow="false"
data-color="var(--color-background)"
data-placement="top"
data-styles="{"body":{"border":"1px solid var(--color-border)","padding":"12px","borderRadius":"8px"}}"
data-styles="{"body":{"padding":0,"borderRadius":"8px","overflow":"hidden"}}"
data-testid="popover"
>
<div
data-testid="popover-content"
>
<div
class="c0"
data-testid="og-card"
>
<img
alt="https://example.com/path with space"
data-hostname="example.com"
data-testid="favicon"
/>
<span>
<div
data-testid="title"
>
example.com
</div>
<div
data-testid="text"
>
https://example.com/path with space
</span>
</div>
</div>
</div>
<div