From dc21dd1802cae33160d226b4d992cc09c0817f31 Mon Sep 17 00:00:00 2001
From: Doekin <105162544+Doekin@users.noreply.github.com>
Date: Fri, 6 Jun 2025 18:52:45 +0800
Subject: [PATCH] refactor: unified image viewer with integrated context menu
(#6892)
* fix(Markdown): eliminate hydration error from image `
` nested in `
`
Signed-off-by: Chan Lee
* feat: add support for reading local files in binary format
Signed-off-by: Chan Lee
* refactor(ImageViewer): Consolidate image rendering for unified display and context menu
Signed-off-by: Chan Lee
---------
Signed-off-by: Chan Lee
---
src/main/services/FileService.ts | 8 +-
src/preload/index.ts | 2 +-
src/renderer/src/components/ImageViewer.tsx | 141 ++++++++++++++++++
src/renderer/src/pages/agents/index.ts | 2 +-
.../src/pages/home/Markdown/Markdown.tsx | 11 +-
.../pages/home/Messages/Blocks/ImageBlock.tsx | 28 +++-
.../pages/paintings/components/Artboard.tsx | 55 ++-----
7 files changed, 194 insertions(+), 53 deletions(-)
create mode 100644 src/renderer/src/components/ImageViewer.tsx
diff --git a/src/main/services/FileService.ts b/src/main/services/FileService.ts
index 39255e15f7..a964d43a8b 100644
--- a/src/main/services/FileService.ts
+++ b/src/main/services/FileService.ts
@@ -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)
}
}
diff --git a/src/preload/index.ts b/src/preload/index.ts
index ae23883f9a..f22fef7d7a 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -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)
diff --git a/src/renderer/src/components/ImageViewer.tsx b/src/renderer/src/components/ImageViewer.tsx
new file mode 100644
index 0000000000..e9f9be1691
--- /dev/null
+++ b/src/renderer/src/components/ImageViewer.tsx
@@ -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 = ({ 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: ,
+ onClick: () => {
+ navigator.clipboard.writeText(src)
+ window.message.success(t('message.copy.success'))
+ }
+ },
+ {
+ key: 'download',
+ label: t('common.download'),
+ icon: ,
+ onClick: () => download(src)
+ },
+ {
+ key: 'copy-image',
+ label: t('code_block.preview.copy.image'),
+ icon: ,
+ onClick: () => handleCopyImage(src)
+ }
+ ]
+ }
+
+ return (
+
+ (
+
+
+
+
+
+
+
+
+ handleCopyImage(src)} />
+ download(src)} />
+
+ )
+ }}
+ />
+
+ )
+}
+
+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
diff --git a/src/renderer/src/pages/agents/index.ts b/src/renderer/src/pages/agents/index.ts
index 3bc31bd1be..711ec59a61 100644
--- a/src/renderer/src/pages/agents/index.ts
+++ b/src/renderer/src/pages/agents/index.ts
@@ -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[]
}
diff --git a/src/renderer/src/pages/home/Markdown/Markdown.tsx b/src/renderer/src/pages/home/Markdown/Markdown.tsx
index 9c9884abac..686017fe28 100644
--- a/src/renderer/src/pages/home/Markdown/Markdown.tsx
+++ b/src/renderer/src/pages/home/Markdown/Markdown.tsx
@@ -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 = ({ block }) => {
code: (props: any) => (
),
- img: ImagePreview,
- pre: (props: any) =>
+ img: (props: any) => ,
+ pre: (props: any) => ,
+ p: (props) => {
+ const hasImage = props?.node?.children?.some((child: any) => child.tagName === 'img')
+ if (hasImage) return
+ return
+ }
} as Partial
}, [onSaveCodeBlock])
diff --git a/src/renderer/src/pages/home/Messages/Blocks/ImageBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ImageBlock.tsx
index 5ede5ec773..ba11fb1a08 100644
--- a/src/renderer/src/pages/home/Messages/Blocks/ImageBlock.tsx
+++ b/src/renderer/src/pages/home/Messages/Blocks/ImageBlock.tsx
@@ -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 = ({ block }) => {
- return block.status === 'success' ? :
+ if (block.status !== 'success') return
+ const images = block.metadata?.generateImageResponse?.images?.length
+ ? block.metadata?.generateImageResponse?.images
+ : block?.file?.path
+ ? [`file://${block?.file?.path}`]
+ : []
+ return (
+
+ {images.map((src, index) => (
+
+ ))}
+
+ )
}
+const Container = styled.div`
+ display: flex;
+ flex-direction: row;
+ gap: 10px;
+ margin-top: 8px;
+`
export default React.memo(ImageBlock)
diff --git a/src/renderer/src/pages/paintings/components/Artboard.tsx b/src/renderer/src/pages/paintings/components/Artboard.tsx
index aa20065f22..6822237787 100644
--- a/src/renderer/src/pages/paintings/components/Artboard.tsx
+++ b/src/renderer/src/pages/paintings/components/Artboard.tsx
@@ -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 = ({
return currentFile ? FileManager.getFileUrl(currentFile) : ''
}
- const handleContextMenu = (e: React.MouseEvent) => {
- e.preventDefault()
- }
-
- const getContextMenuItems = () => {
- return [
- {
- key: 'copy',
- label: t('common.copy'),
- icon: ,
- onClick: () => {
- navigator.clipboard.writeText(painting.urls[currentImageIndex])
- }
- },
- {
- key: 'download',
- label: t('common.download'),
- icon: ,
- onClick: () => download(getCurrentImageUrl())
- }
- ]
- }
-
return (
@@ -70,20 +44,17 @@ const Artboard: FC = ({
←
)}
-
-
-
+
{painting.files.length > 1 && (
→