mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-09 23:10:20 +08:00
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:
parent
fa00b5b173
commit
653bfa1f17
@ -1,7 +1,9 @@
|
|||||||
import fs from 'node:fs'
|
import fs from 'fs/promises'
|
||||||
|
|
||||||
export default class FileService {
|
export default class FileService {
|
||||||
public static async readFile(_: Electron.IpcMainInvokeEvent, path: string) {
|
public static async readFile(_: Electron.IpcMainInvokeEvent, pathOrUrl: string, encoding?: BufferEncoding) {
|
||||||
return fs.readFileSync(path, 'utf8')
|
const path = pathOrUrl.startsWith('file://') ? new URL(pathOrUrl) : pathOrUrl
|
||||||
|
if (encoding) return fs.readFile(path, { encoding })
|
||||||
|
return fs.readFile(path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -86,7 +86,7 @@ const api = {
|
|||||||
getPathForFile: (file: File) => webUtils.getPathForFile(file)
|
getPathForFile: (file: File) => webUtils.getPathForFile(file)
|
||||||
},
|
},
|
||||||
fs: {
|
fs: {
|
||||||
read: (path: string) => ipcRenderer.invoke(IpcChannel.Fs_Read, path)
|
read: (pathOrUrl: string, encoding?: BufferEncoding) => ipcRenderer.invoke(IpcChannel.Fs_Read, pathOrUrl, encoding)
|
||||||
},
|
},
|
||||||
export: {
|
export: {
|
||||||
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_Word, markdown, fileName)
|
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_Word, markdown, fileName)
|
||||||
|
|||||||
141
src/renderer/src/components/ImageViewer.tsx
Normal file
141
src/renderer/src/components/ImageViewer.tsx
Normal 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
|
||||||
@ -45,7 +45,7 @@ export function useSystemAgents() {
|
|||||||
|
|
||||||
// 如果没有远程配置或获取失败,加载本地代理
|
// 如果没有远程配置或获取失败,加载本地代理
|
||||||
if (resourcesPath && _agents.length === 0) {
|
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[]
|
_agents = JSON.parse(localAgentsData) as Agent[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'katex/dist/katex.min.css'
|
|||||||
import 'katex/dist/contrib/copy-tex'
|
import 'katex/dist/contrib/copy-tex'
|
||||||
import 'katex/dist/contrib/mhchem'
|
import 'katex/dist/contrib/mhchem'
|
||||||
|
|
||||||
|
import ImageViewer from '@renderer/components/ImageViewer'
|
||||||
import MarkdownShadowDOMRenderer from '@renderer/components/MarkdownShadowDOMRenderer'
|
import MarkdownShadowDOMRenderer from '@renderer/components/MarkdownShadowDOMRenderer'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||||
@ -22,7 +23,6 @@ import remarkGfm from 'remark-gfm'
|
|||||||
import remarkMath from 'remark-math'
|
import remarkMath from 'remark-math'
|
||||||
|
|
||||||
import CodeBlock from './CodeBlock'
|
import CodeBlock from './CodeBlock'
|
||||||
import ImagePreview from './ImagePreview'
|
|
||||||
import Link from './Link'
|
import Link from './Link'
|
||||||
|
|
||||||
const ALLOWED_ELEMENTS =
|
const ALLOWED_ELEMENTS =
|
||||||
@ -83,8 +83,13 @@ const Markdown: FC<Props> = ({ block }) => {
|
|||||||
code: (props: any) => (
|
code: (props: any) => (
|
||||||
<CodeBlock {...props} id={getCodeBlockId(props?.node?.position?.start)} onSave={onSaveCodeBlock} />
|
<CodeBlock {...props} id={getCodeBlockId(props?.node?.position?.start)} onSave={onSaveCodeBlock} />
|
||||||
),
|
),
|
||||||
img: ImagePreview,
|
img: (props: any) => <ImageViewer style={{ maxWidth: 500, maxHeight: 500 }} {...props} />,
|
||||||
pre: (props: any) => <pre style={{ overflow: 'visible' }} {...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>
|
} as Partial<Components>
|
||||||
}, [onSaveCodeBlock])
|
}, [onSaveCodeBlock])
|
||||||
|
|
||||||
|
|||||||
@ -1,15 +1,37 @@
|
|||||||
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
|
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
|
||||||
|
import ImageViewer from '@renderer/components/ImageViewer'
|
||||||
import type { ImageMessageBlock } from '@renderer/types/newMessage'
|
import type { ImageMessageBlock } from '@renderer/types/newMessage'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import styled from 'styled-components'
|
||||||
import MessageImage from '../MessageImage'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
block: ImageMessageBlock
|
block: ImageMessageBlock
|
||||||
}
|
}
|
||||||
|
|
||||||
const ImageBlock: React.FC<Props> = ({ block }) => {
|
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)
|
export default React.memo(ImageBlock)
|
||||||
|
|||||||
@ -1,14 +1,11 @@
|
|||||||
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons'
|
import ImageViewer from '@renderer/components/ImageViewer'
|
||||||
import FileManager from '@renderer/services/FileManager'
|
import FileManager from '@renderer/services/FileManager'
|
||||||
import { Painting } from '@renderer/types'
|
import { Painting } from '@renderer/types'
|
||||||
import { download } from '@renderer/utils/download'
|
import { Button, Spin } from 'antd'
|
||||||
import { Button, Dropdown, Spin } from 'antd'
|
|
||||||
import React, { FC } from 'react'
|
import React, { FC } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import ImagePreview from '../../home/Markdown/ImagePreview'
|
|
||||||
|
|
||||||
interface ArtboardProps {
|
interface ArtboardProps {
|
||||||
painting: Painting
|
painting: Painting
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
@ -37,29 +34,6 @@ const Artboard: FC<ArtboardProps> = ({
|
|||||||
return currentFile ? FileManager.getFileUrl(currentFile) : ''
|
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 (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<LoadingContainer spinning={isLoading}>
|
<LoadingContainer spinning={isLoading}>
|
||||||
@ -70,20 +44,17 @@ const Artboard: FC<ArtboardProps> = ({
|
|||||||
←
|
←
|
||||||
</NavigationButton>
|
</NavigationButton>
|
||||||
)}
|
)}
|
||||||
<Dropdown menu={{ items: getContextMenuItems() }} trigger={['contextMenu']}>
|
<ImageViewer
|
||||||
<ImagePreview
|
src={getCurrentImageUrl()}
|
||||||
src={getCurrentImageUrl()}
|
preview={{ mask: false }}
|
||||||
preview={{ mask: false }}
|
style={{
|
||||||
onContextMenu={handleContextMenu}
|
maxWidth: '70vh',
|
||||||
style={{
|
maxHeight: '70vh',
|
||||||
maxWidth: '70vh',
|
objectFit: 'contain',
|
||||||
maxHeight: '70vh',
|
backgroundColor: 'var(--color-background-soft)',
|
||||||
objectFit: 'contain',
|
cursor: 'pointer'
|
||||||
backgroundColor: 'var(--color-background-soft)',
|
}}
|
||||||
cursor: 'pointer'
|
/>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
{painting.files.length > 1 && (
|
{painting.files.length > 1 && (
|
||||||
<NavigationButton onClick={onNextImage} style={{ right: 10 }}>
|
<NavigationButton onClick={onNextImage} style={{ right: 10 }}>
|
||||||
→
|
→
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user