feat: add mermaid preview and download feature
This commit is contained in:
parent
79cabadfb8
commit
d258c1cfe2
@ -1,7 +1,9 @@
|
||||
import { locales } from '@main/utils/locales'
|
||||
import { app, Menu, nativeImage, nativeTheme, Tray } from 'electron'
|
||||
|
||||
import iconDark from '../../../build/tray_icon_dark.png?asset'
|
||||
import iconLight from '../../../build/tray_icon_light.png?asset'
|
||||
import { configManager } from './ConfigManager'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
export class TrayService {
|
||||
@ -40,13 +42,16 @@ export class TrayService {
|
||||
|
||||
this.tray = tray
|
||||
|
||||
const locale = locales[configManager.getLanguage()]
|
||||
const { tray: trayLocale } = locale.translation
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: '显示窗口',
|
||||
label: trayLocale.show_window,
|
||||
click: () => windowService.showMainWindow()
|
||||
},
|
||||
{
|
||||
label: '退出',
|
||||
label: trayLocale.quit,
|
||||
click: () => this.quit()
|
||||
}
|
||||
])
|
||||
|
||||
@ -4,8 +4,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: *; frame-src * file:" />
|
||||
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
|
||||
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
@ -37,4 +38,4 @@
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
@ -112,6 +112,9 @@
|
||||
overflow-x: auto;
|
||||
font-family: 'Fira Code', 'Courier New', Courier, monospace;
|
||||
background-color: var(--color-background-mute);
|
||||
&:has(> .mermaid) {
|
||||
background-color: transparent;
|
||||
}
|
||||
&:not(pre pre) {
|
||||
> code:not(pre pre > code) {
|
||||
padding: 15px;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -60,7 +60,6 @@ export const SyntaxHighlighterProvider: React.FC<PropsWithChildren> = ({ childre
|
||||
await highlighter.loadLanguage(language as BundledLanguage)
|
||||
console.log(`Loaded language: ${language}`)
|
||||
} else {
|
||||
console.warn(`Language '${language}' is not supported`)
|
||||
return `<pre><code>${code}</code></pre>`
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,25 +13,13 @@ export const useMermaid = () => {
|
||||
runAsyncFunction(async () => {
|
||||
if (!window.mermaid) {
|
||||
await loadScript('https://unpkg.com/mermaid@11.4.0/dist/mermaid.min.js')
|
||||
window.mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
theme: theme === ThemeMode.dark ? 'dark' : 'default'
|
||||
})
|
||||
window.mermaid.contentLoaded()
|
||||
}
|
||||
window.mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
theme: theme === ThemeMode.dark ? 'dark' : 'default'
|
||||
})
|
||||
window.mermaid.contentLoaded()
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (window.mermaid) {
|
||||
window.mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
theme: theme === ThemeMode.dark ? 'dark' : 'default'
|
||||
})
|
||||
window.mermaid.contentLoaded()
|
||||
}
|
||||
}, 2000)
|
||||
}, [theme])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -406,6 +406,21 @@
|
||||
"messages": "Messages",
|
||||
"conversation_details": "Conversation Details",
|
||||
"conversation_history": "Conversation History"
|
||||
},
|
||||
"mermaid": {
|
||||
"title": "Mermaid Diagram",
|
||||
"download": {
|
||||
"svg": "Download SVG",
|
||||
"png": "Download PNG"
|
||||
},
|
||||
"tabs": {
|
||||
"preview": "Preview",
|
||||
"source": "Source"
|
||||
}
|
||||
},
|
||||
"tray": {
|
||||
"show_window": "Show Window",
|
||||
"quit": "Quit"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -406,6 +406,21 @@
|
||||
"messages": "消息数",
|
||||
"conversation_details": "会话详情",
|
||||
"conversation_history": "会话历史"
|
||||
},
|
||||
"mermaid": {
|
||||
"title": "Mermaid 图表",
|
||||
"download": {
|
||||
"svg": "下载 SVG",
|
||||
"png": "下载 PNG"
|
||||
},
|
||||
"tabs": {
|
||||
"preview": "预览",
|
||||
"source": "源码"
|
||||
}
|
||||
},
|
||||
"tray": {
|
||||
"show_window": "显示窗口",
|
||||
"quit": "退出"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -406,6 +406,21 @@
|
||||
"messages": "訊息數",
|
||||
"conversation_details": "會話詳情",
|
||||
"conversation_history": "會話歷史"
|
||||
},
|
||||
"mermaid": {
|
||||
"title": "Mermaid 圖表",
|
||||
"download": {
|
||||
"svg": "下載 SVG",
|
||||
"png": "下載 PNG"
|
||||
},
|
||||
"tabs": {
|
||||
"preview": "預覽",
|
||||
"source": "原始碼"
|
||||
}
|
||||
},
|
||||
"tray": {
|
||||
"show_window": "顯示視窗",
|
||||
"quit": "退出"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -113,7 +113,7 @@ const AgentsPage: FC = () => {
|
||||
const tabItems = useMemo(() => {
|
||||
let groups = Object.keys(filteredAgentGroups)
|
||||
|
||||
groups = groups.includes('办公') ? [groups[0], '办公', ...groups.slice(1)] : groups
|
||||
groups = groups.includes('精选') ? [groups[0], '精选', ...groups.slice(1)] : groups
|
||||
|
||||
return groups.map((group, i) => {
|
||||
const id = String(i + 1)
|
||||
|
||||
@ -47,10 +47,10 @@ export const groupTranslations: GroupTranslations = {
|
||||
'zh-CN': '写作',
|
||||
'zh-TW': '寫作'
|
||||
},
|
||||
Artifacts: {
|
||||
'en-US': 'Artifacts',
|
||||
'zh-CN': 'Artifacts',
|
||||
'zh-TW': 'Artifacts'
|
||||
精选: {
|
||||
'en-US': 'Featured',
|
||||
'zh-CN': '精选',
|
||||
'zh-TW': '精選'
|
||||
},
|
||||
编程: {
|
||||
'en-US': 'Programming',
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
import MermaidPopup from './MermaidPopup'
|
||||
|
||||
interface Props {
|
||||
chart: string
|
||||
}
|
||||
@ -9,7 +11,15 @@ const Mermaid: React.FC<Props> = ({ chart }) => {
|
||||
window?.mermaid?.contentLoaded()
|
||||
}, [])
|
||||
|
||||
return <div className="mermaid">{chart}</div>
|
||||
const onPreview = () => {
|
||||
MermaidPopup.show({ chart })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mermaid" onClick={onPreview} style={{ cursor: 'pointer' }}>
|
||||
{chart}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Mermaid
|
||||
|
||||
164
src/renderer/src/pages/home/Markdown/MermaidPopup.tsx
Normal file
164
src/renderer/src/pages/home/Markdown/MermaidPopup.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import { download } from '@renderer/utils/download'
|
||||
import { Button, Modal, Space, Tabs } from 'antd'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface ShowParams {
|
||||
chart: string
|
||||
}
|
||||
|
||||
interface Props extends ShowParams {
|
||||
resolve: (data: any) => void
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ resolve, chart }) => {
|
||||
const [open, setOpen] = useState(true)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const onOk = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
resolve({})
|
||||
}
|
||||
|
||||
const handleDownload = async (format: 'svg' | 'png') => {
|
||||
try {
|
||||
const element = document.querySelector('.mermaid')
|
||||
if (!element) return
|
||||
|
||||
const timestamp = Date.now()
|
||||
|
||||
if (format === 'svg') {
|
||||
const svgElement = element.querySelector('svg')
|
||||
if (!svgElement) return
|
||||
const svgData = new XMLSerializer().serializeToString(svgElement)
|
||||
const blob = new Blob([svgData], { type: 'image/svg+xml' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
download(url, `mermaid-diagram-${timestamp}.svg`)
|
||||
URL.revokeObjectURL(url)
|
||||
} else if (format === 'png') {
|
||||
const svgElement = element.querySelector('svg')
|
||||
if (!svgElement) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const img = new Image()
|
||||
|
||||
const viewBox = svgElement.getAttribute('viewBox')?.split(' ').map(Number) || []
|
||||
const width = viewBox[2] || svgElement.clientWidth || svgElement.getBoundingClientRect().width
|
||||
const height = viewBox[3] || svgElement.clientHeight || svgElement.getBoundingClientRect().height
|
||||
|
||||
const svgData = new XMLSerializer().serializeToString(svgElement)
|
||||
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' })
|
||||
const url = URL.createObjectURL(svgBlob)
|
||||
|
||||
img.onload = () => {
|
||||
const scale = 3
|
||||
canvas.width = width * scale
|
||||
canvas.height = height * scale
|
||||
|
||||
if (ctx) {
|
||||
ctx.scale(scale, scale)
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
}
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const pngUrl = URL.createObjectURL(blob)
|
||||
download(pngUrl, `mermaid-diagram-${timestamp}.png`)
|
||||
URL.revokeObjectURL(pngUrl)
|
||||
}
|
||||
}, 'image/png')
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
img.src = url
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
window?.mermaid?.contentLoaded()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('mermaid.title')}
|
||||
open={open}
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
width={1000}
|
||||
footer={[
|
||||
<Space key="download-buttons">
|
||||
<Button onClick={() => handleDownload('svg')}>{t('mermaid.download.svg')}</Button>
|
||||
<Button onClick={() => handleDownload('png')}>{t('mermaid.download.png')}</Button>
|
||||
</Space>
|
||||
]}>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'preview',
|
||||
label: t('mermaid.tabs.preview'),
|
||||
children: (
|
||||
<div
|
||||
className="mermaid"
|
||||
style={{
|
||||
maxHeight: 'calc(80vh - 200px)',
|
||||
overflowY: 'auto',
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
{chart}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'source',
|
||||
label: t('mermaid.tabs.source'),
|
||||
children: (
|
||||
<pre
|
||||
style={{
|
||||
maxHeight: 'calc(80vh - 200px)',
|
||||
overflowY: 'auto',
|
||||
padding: '16px'
|
||||
}}>
|
||||
{chart}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default class MermaidPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
TopView.hide('MermaidPopup')
|
||||
}
|
||||
static show(props: ShowParams) {
|
||||
return new Promise<any>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
{...props}
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
this.hide()
|
||||
}}
|
||||
/>,
|
||||
'MermaidPopup'
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,44 +1,57 @@
|
||||
export const download = (url: string) => {
|
||||
export const download = (url: string, filename?: string) => {
|
||||
// 处理 file:// 协议
|
||||
if (url.startsWith('file://')) {
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = url.split('/').pop() || 'download'
|
||||
link.download = filename || url.split('/').pop() || 'download'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
return
|
||||
}
|
||||
|
||||
// 处理 Blob URL
|
||||
if (url.startsWith('blob:')) {
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename || `${Date.now()}_diagram.svg`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
return
|
||||
}
|
||||
|
||||
// 处理普通 URL
|
||||
fetch(url)
|
||||
.then((response) => {
|
||||
// 尝试从Content-Disposition头获取文件名
|
||||
const contentDisposition = response.headers.get('Content-Disposition')
|
||||
let filename = 'download' // 默认文件名
|
||||
let finalFilename = filename || 'download'
|
||||
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i)
|
||||
if (filenameMatch) {
|
||||
filename = filenameMatch[1]
|
||||
if (!filename) {
|
||||
// 尝试从Content-Disposition头获取文件名
|
||||
const contentDisposition = response.headers.get('Content-Disposition')
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i)
|
||||
if (filenameMatch) {
|
||||
finalFilename = filenameMatch[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果URL中有文件名,使用URL中的文件名
|
||||
const urlFilename = url.split('/').pop()
|
||||
if (urlFilename && urlFilename.includes('.')) {
|
||||
filename = urlFilename
|
||||
}
|
||||
// 如果URL中有文件名,使用URL中的文件名
|
||||
const urlFilename = url.split('/').pop()
|
||||
if (urlFilename && urlFilename.includes('.')) {
|
||||
finalFilename = urlFilename
|
||||
}
|
||||
|
||||
// 如果文件名没有后缀,根据Content-Type添加后缀
|
||||
if (!filename.includes('.')) {
|
||||
const contentType = response.headers.get('Content-Type')
|
||||
const extension = getExtensionFromMimeType(contentType)
|
||||
filename += extension
|
||||
}
|
||||
// 如果文件名没有后缀,根据Content-Type添加后缀
|
||||
if (!finalFilename.includes('.')) {
|
||||
const contentType = response.headers.get('Content-Type')
|
||||
const extension = getExtensionFromMimeType(contentType)
|
||||
finalFilename += extension
|
||||
}
|
||||
|
||||
// 添加时间戳以确保文件名唯一
|
||||
const timestamp = Date.now()
|
||||
const finalFilename = `${timestamp}_${filename}`
|
||||
// 添加时间戳以确保文件名唯一
|
||||
finalFilename = `${Date.now()}_${finalFilename}`
|
||||
}
|
||||
|
||||
return response.blob().then((blob) => ({ blob, finalFilename }))
|
||||
})
|
||||
@ -62,6 +75,7 @@ function getExtensionFromMimeType(mimeType: string | null): string {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/gif': '.gif',
|
||||
'image/svg+xml': '.svg',
|
||||
'application/pdf': '.pdf',
|
||||
'text/plain': '.txt',
|
||||
'application/msword': '.doc',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user