mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-19 06:30:10 +08:00
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:
parent
5ce7261678
commit
77c2255da4
@ -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",
|
||||
|
||||
BIN
src/renderer/src/assets/images/banner.png
Normal file
BIN
src/renderer/src/assets/images/banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 299 KiB |
145
src/renderer/src/components/OGCard.tsx
Normal file
145
src/renderer/src/components/OGCard.tsx
Normal 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;
|
||||
`
|
||||
78
src/renderer/src/hooks/useMetaDataParser.ts
Normal file
78
src/renderer/src/hooks/useMetaDataParser.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user