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 && (