refactor: unified image viewer with integrated context menu (#6892)

* fix(Markdown): eliminate hydration error from image `<div>` nested in `<p>`

Signed-off-by: Chan Lee <Leetimemp@gmail.com>

* feat: add support for reading local files in binary format

Signed-off-by: Chan Lee <Leetimemp@gmail.com>

* refactor(ImageViewer): Consolidate image rendering for unified display and context menu

Signed-off-by: Chan Lee <Leetimemp@gmail.com>

---------

Signed-off-by: Chan Lee <Leetimemp@gmail.com>
This commit is contained in:
Doekin 2025-06-06 18:52:45 +08:00 committed by GitHub
parent 88a3a74c76
commit dc21dd1802
7 changed files with 194 additions and 53 deletions

View File

@ -1,7 +1,9 @@
import fs from 'node:fs'
import fs from 'fs/promises'
export default class FileService {
public static async readFile(_: Electron.IpcMainInvokeEvent, path: string) {
return fs.readFileSync(path, 'utf8')
public static async readFile(_: Electron.IpcMainInvokeEvent, pathOrUrl: string, encoding?: BufferEncoding) {
const path = pathOrUrl.startsWith('file://') ? new URL(pathOrUrl) : pathOrUrl
if (encoding) return fs.readFile(path, { encoding })
return fs.readFile(path)
}
}

View File

@ -86,7 +86,7 @@ const api = {
getPathForFile: (file: File) => webUtils.getPathForFile(file)
},
fs: {
read: (path: string) => ipcRenderer.invoke(IpcChannel.Fs_Read, path)
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding)
},
export: {
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_Word, markdown, fileName)

View File

@ -0,0 +1,141 @@
import {
CopyOutlined,
DownloadOutlined,
FileImageOutlined,
RotateLeftOutlined,
RotateRightOutlined,
SwapOutlined,
UndoOutlined,
ZoomInOutlined,
ZoomOutOutlined
} from '@ant-design/icons'
import { download } from '@renderer/utils/download'
import { Dropdown, Image as AntImage, ImageProps as AntImageProps, Space } from 'antd'
import { Base64 } from 'js-base64'
import mime from 'mime'
import React from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface ImageViewerProps extends AntImageProps {
src: string
}
const ImageViewer: React.FC<ImageViewerProps> = ({ src, style, ...props }) => {
const { t } = useTranslation()
// 复制图片到剪贴板
const handleCopyImage = async (src: string) => {
try {
if (src.startsWith('data:')) {
// 处理 base64 格式的图片
const match = src.match(/^data:(image\/\w+);base64,(.+)$/)
if (!match) throw new Error('无效的 base64 图片格式')
const mimeType = match[1]
const byteArray = Base64.toUint8Array(match[2])
const blob = new Blob([byteArray], { type: mimeType })
await navigator.clipboard.write([new ClipboardItem({ [mimeType]: blob })])
} else if (src.startsWith('file://')) {
// 处理本地文件路径
const bytes = await window.api.fs.read(src)
const mimeType = mime.getType(src) || 'application/octet-stream'
const blob = new Blob([bytes], { type: mimeType })
await navigator.clipboard.write([
new ClipboardItem({
[mimeType]: blob
})
])
} else {
// 处理 URL 格式的图片
const response = await fetch(src)
const blob = await response.blob()
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob
})
])
}
window.message.success(t('message.copy.success'))
} catch (error) {
console.error('复制图片失败:', error)
window.message.error(t('message.copy.failed'))
}
}
const getContextMenuItems = (src: string) => {
return [
{
key: 'copy-url',
label: t('common.copy'),
icon: <CopyOutlined />,
onClick: () => {
navigator.clipboard.writeText(src)
window.message.success(t('message.copy.success'))
}
},
{
key: 'download',
label: t('common.download'),
icon: <DownloadOutlined />,
onClick: () => download(src)
},
{
key: 'copy-image',
label: t('code_block.preview.copy.image'),
icon: <FileImageOutlined />,
onClick: () => handleCopyImage(src)
}
]
}
return (
<Dropdown menu={{ items: getContextMenuItems(src) }} trigger={['contextMenu']}>
<AntImage
src={src}
style={style}
{...props}
preview={{
mask: typeof props.preview === 'object' ? props.preview.mask : false,
toolbarRender: (
_,
{
transform: { scale },
actions: { onFlipY, onFlipX, onRotateLeft, onRotateRight, onZoomOut, onZoomIn, onReset }
}
) => (
<ToolbarWrapper size={12} className="toolbar-wrapper">
<SwapOutlined rotate={90} onClick={onFlipY} />
<SwapOutlined onClick={onFlipX} />
<RotateLeftOutlined onClick={onRotateLeft} />
<RotateRightOutlined onClick={onRotateRight} />
<ZoomOutOutlined disabled={scale === 1} onClick={onZoomOut} />
<ZoomInOutlined disabled={scale === 50} onClick={onZoomIn} />
<UndoOutlined onClick={onReset} />
<CopyOutlined onClick={() => handleCopyImage(src)} />
<DownloadOutlined onClick={() => download(src)} />
</ToolbarWrapper>
)
}}
/>
</Dropdown>
)
}
const ToolbarWrapper = styled(Space)`
padding: 0px 24px;
color: #fff;
font-size: 20px;
background-color: rgba(0, 0, 0, 0.1);
border-radius: 100px;
.anticon {
padding: 12px;
cursor: pointer;
}
.anticon:hover {
opacity: 0.3;
}
`
export default ImageViewer

View File

@ -45,7 +45,7 @@ export function useSystemAgents() {
// 如果没有远程配置或获取失败,加载本地代理
if (resourcesPath && _agents.length === 0) {
const localAgentsData = await window.api.fs.read(resourcesPath + '/data/agents.json')
const localAgentsData = await window.api.fs.read(resourcesPath + '/data/agents.json', 'utf-8')
_agents = JSON.parse(localAgentsData) as Agent[]
}

View File

@ -2,6 +2,7 @@ import 'katex/dist/katex.min.css'
import 'katex/dist/contrib/copy-tex'
import 'katex/dist/contrib/mhchem'
import ImageViewer from '@renderer/components/ImageViewer'
import MarkdownShadowDOMRenderer from '@renderer/components/MarkdownShadowDOMRenderer'
import { useSettings } from '@renderer/hooks/useSettings'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
@ -22,7 +23,6 @@ import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import CodeBlock from './CodeBlock'
import ImagePreview from './ImagePreview'
import Link from './Link'
const ALLOWED_ELEMENTS =
@ -83,8 +83,13 @@ const Markdown: FC<Props> = ({ block }) => {
code: (props: any) => (
<CodeBlock {...props} id={getCodeBlockId(props?.node?.position?.start)} onSave={onSaveCodeBlock} />
),
img: ImagePreview,
pre: (props: any) => <pre style={{ overflow: 'visible' }} {...props} />
img: (props: any) => <ImageViewer style={{ maxWidth: 500, maxHeight: 500 }} {...props} />,
pre: (props: any) => <pre style={{ overflow: 'visible' }} {...props} />,
p: (props) => {
const hasImage = props?.node?.children?.some((child: any) => child.tagName === 'img')
if (hasImage) return <div {...props} />
return <p {...props} />
}
} as Partial<Components>
}, [onSaveCodeBlock])

View File

@ -1,15 +1,37 @@
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
import ImageViewer from '@renderer/components/ImageViewer'
import type { ImageMessageBlock } from '@renderer/types/newMessage'
import React from 'react'
import MessageImage from '../MessageImage'
import styled from 'styled-components'
interface Props {
block: ImageMessageBlock
}
const ImageBlock: React.FC<Props> = ({ block }) => {
return block.status === 'success' ? <MessageImage block={block} /> : <SvgSpinners180Ring />
if (block.status !== 'success') return <SvgSpinners180Ring />
const images = block.metadata?.generateImageResponse?.images?.length
? block.metadata?.generateImageResponse?.images
: block?.file?.path
? [`file://${block?.file?.path}`]
: []
return (
<Container style={{ marginBottom: 8 }}>
{images.map((src, index) => (
<ImageViewer
src={src}
key={`image-${index}`}
style={{ maxWidth: 500, maxHeight: 500, padding: 5, borderRadius: 8 }}
/>
))}
</Container>
)
}
const Container = styled.div`
display: flex;
flex-direction: row;
gap: 10px;
margin-top: 8px;
`
export default React.memo(ImageBlock)

View File

@ -1,14 +1,11 @@
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons'
import ImageViewer from '@renderer/components/ImageViewer'
import FileManager from '@renderer/services/FileManager'
import { Painting } from '@renderer/types'
import { download } from '@renderer/utils/download'
import { Button, Dropdown, Spin } from 'antd'
import { Button, Spin } from 'antd'
import React, { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import ImagePreview from '../../home/Markdown/ImagePreview'
interface ArtboardProps {
painting: Painting
isLoading: boolean
@ -37,29 +34,6 @@ const Artboard: FC<ArtboardProps> = ({
return currentFile ? FileManager.getFileUrl(currentFile) : ''
}
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault()
}
const getContextMenuItems = () => {
return [
{
key: 'copy',
label: t('common.copy'),
icon: <CopyOutlined />,
onClick: () => {
navigator.clipboard.writeText(painting.urls[currentImageIndex])
}
},
{
key: 'download',
label: t('common.download'),
icon: <DownloadOutlined />,
onClick: () => download(getCurrentImageUrl())
}
]
}
return (
<Container>
<LoadingContainer spinning={isLoading}>
@ -70,20 +44,17 @@ const Artboard: FC<ArtboardProps> = ({
</NavigationButton>
)}
<Dropdown menu={{ items: getContextMenuItems() }} trigger={['contextMenu']}>
<ImagePreview
src={getCurrentImageUrl()}
preview={{ mask: false }}
onContextMenu={handleContextMenu}
style={{
maxWidth: '70vh',
maxHeight: '70vh',
objectFit: 'contain',
backgroundColor: 'var(--color-background-soft)',
cursor: 'pointer'
}}
/>
</Dropdown>
<ImageViewer
src={getCurrentImageUrl()}
preview={{ mask: false }}
style={{
maxWidth: '70vh',
maxHeight: '70vh',
objectFit: 'contain',
backgroundColor: 'var(--color-background-soft)',
cursor: 'pointer'
}}
/>
{painting.files.length > 1 && (
<NavigationButton onClick={onNextImage} style={{ right: 10 }}>