feat: export topic message as image #103
This commit is contained in:
parent
13fddc8e7f
commit
eb799879ff
@ -34,7 +34,8 @@
|
|||||||
"electron-log": "^5.1.5",
|
"electron-log": "^5.1.5",
|
||||||
"electron-store": "^8.2.0",
|
"electron-store": "^8.2.0",
|
||||||
"electron-updater": "^6.1.7",
|
"electron-updater": "^6.1.7",
|
||||||
"electron-window-state": "^5.0.3"
|
"electron-window-state": "^5.0.3",
|
||||||
|
"html2canvas": "^1.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.24.3",
|
"@anthropic-ai/sdk": "^0.24.3",
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { BrowserWindow, ipcMain, OpenDialogOptions, session, shell } from 'elect
|
|||||||
import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config'
|
import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config'
|
||||||
import AppUpdater from './services/AppUpdater'
|
import AppUpdater from './services/AppUpdater'
|
||||||
import FileManager from './services/FileManager'
|
import FileManager from './services/FileManager'
|
||||||
import { openFile, saveFile } from './utils/file'
|
|
||||||
import { compress, decompress } from './utils/zip'
|
import { compress, decompress } from './utils/zip'
|
||||||
import { createMinappWindow } from './window'
|
import { createMinappWindow } from './window'
|
||||||
|
|
||||||
@ -28,13 +27,14 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
session.defaultSession.setProxy(proxy ? { proxyRules: proxy } : {})
|
session.defaultSession.setProxy(proxy ? { proxyRules: proxy } : {})
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('save-file', saveFile)
|
|
||||||
ipcMain.handle('open-file', openFile)
|
|
||||||
ipcMain.handle('reload', () => mainWindow.reload())
|
ipcMain.handle('reload', () => mainWindow.reload())
|
||||||
|
|
||||||
ipcMain.handle('zip:compress', (_, text: string) => compress(text))
|
ipcMain.handle('zip:compress', (_, text: string) => compress(text))
|
||||||
ipcMain.handle('zip:decompress', (_, text: Buffer) => decompress(text))
|
ipcMain.handle('zip:decompress', (_, text: Buffer) => decompress(text))
|
||||||
|
|
||||||
|
ipcMain.handle('file:open', fileManager.open)
|
||||||
|
ipcMain.handle('file:save', fileManager.save)
|
||||||
|
ipcMain.handle('file:saveImage', fileManager.saveImage)
|
||||||
ipcMain.handle('file:base64Image', async (_, id) => await fileManager.base64Image(id))
|
ipcMain.handle('file:base64Image', async (_, id) => await fileManager.base64Image(id))
|
||||||
ipcMain.handle('file:select', async (_, options?: OpenDialogOptions) => await fileManager.selectFile(options))
|
ipcMain.handle('file:select', async (_, options?: OpenDialogOptions) => await fileManager.selectFile(options))
|
||||||
ipcMain.handle('file:upload', async (_, file: FileType) => await fileManager.uploadFile(file))
|
ipcMain.handle('file:upload', async (_, file: FileType) => await fileManager.uploadFile(file))
|
||||||
|
|||||||
@ -1,8 +1,18 @@
|
|||||||
import { getFileType } from '@main/utils/file'
|
import { getFileType } from '@main/utils/file'
|
||||||
import { FileType } from '@types'
|
import { FileType } from '@types'
|
||||||
import * as crypto from 'crypto'
|
import * as crypto from 'crypto'
|
||||||
import { app, dialog, OpenDialogOptions } from 'electron'
|
import {
|
||||||
|
app,
|
||||||
|
dialog,
|
||||||
|
OpenDialogOptions,
|
||||||
|
OpenDialogReturnValue,
|
||||||
|
SaveDialogOptions,
|
||||||
|
SaveDialogReturnValue
|
||||||
|
} from 'electron'
|
||||||
|
import logger from 'electron-log'
|
||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
|
import { writeFileSync } from 'fs'
|
||||||
|
import { readFile } from 'fs/promises'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
@ -193,6 +203,69 @@ class FileManager {
|
|||||||
await fs.promises.rmdir(this.storageDir, { recursive: true })
|
await fs.promises.rmdir(this.storageDir, { recursive: true })
|
||||||
await this.initStorageDir()
|
await this.initStorageDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async open(
|
||||||
|
_: Electron.IpcMainInvokeEvent,
|
||||||
|
options: OpenDialogOptions
|
||||||
|
): Promise<{ fileName: string; content: Buffer } | null> {
|
||||||
|
try {
|
||||||
|
const result: OpenDialogReturnValue = await dialog.showOpenDialog({
|
||||||
|
title: '打开文件',
|
||||||
|
properties: ['openFile'],
|
||||||
|
filters: [{ name: '所有文件', extensions: ['*'] }],
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.canceled && result.filePaths.length > 0) {
|
||||||
|
const filePath = result.filePaths[0]
|
||||||
|
const fileName = filePath.split('/').pop() || ''
|
||||||
|
const content = await readFile(filePath)
|
||||||
|
return { fileName, content }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[IPC - Error]', 'An error occurred opening the file:', err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(
|
||||||
|
_: Electron.IpcMainInvokeEvent,
|
||||||
|
fileName: string,
|
||||||
|
content: string,
|
||||||
|
options?: SaveDialogOptions
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result: SaveDialogReturnValue = await dialog.showSaveDialog({
|
||||||
|
title: '保存文件',
|
||||||
|
defaultPath: fileName,
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.canceled && result.filePath) {
|
||||||
|
await writeFileSync(result.filePath, content, { encoding: 'utf-8' })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[IPC - Error]', 'An error occurred saving the file:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveImage(_: Electron.IpcMainInvokeEvent, name: string, data: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const filePath = dialog.showSaveDialogSync({
|
||||||
|
defaultPath: `${name}.png`,
|
||||||
|
filters: [{ name: 'PNG Image', extensions: ['png'] }]
|
||||||
|
})
|
||||||
|
|
||||||
|
if (filePath) {
|
||||||
|
const base64Data = data.replace(/^data:image\/png;base64,/, '')
|
||||||
|
fs.writeFileSync(filePath, base64Data, 'base64')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[IPC - Error]', 'An error occurred saving the image:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FileManager
|
export default FileManager
|
||||||
|
|||||||
@ -1,57 +1,5 @@
|
|||||||
import { dialog, OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron'
|
|
||||||
import logger from 'electron-log'
|
|
||||||
import { writeFileSync } from 'fs'
|
|
||||||
import { readFile } from 'fs/promises'
|
|
||||||
|
|
||||||
import { FileTypes } from '../../renderer/src/types'
|
import { FileTypes } from '../../renderer/src/types'
|
||||||
|
|
||||||
export async function saveFile(
|
|
||||||
_: Electron.IpcMainInvokeEvent,
|
|
||||||
fileName: string,
|
|
||||||
content: string,
|
|
||||||
options?: SaveDialogOptions
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const result: SaveDialogReturnValue = await dialog.showSaveDialog({
|
|
||||||
title: '保存文件',
|
|
||||||
defaultPath: fileName,
|
|
||||||
...options
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result.canceled && result.filePath) {
|
|
||||||
await writeFileSync(result.filePath, content, { encoding: 'utf-8' })
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('[IPC - Error]', 'An error occurred saving the file:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function openFile(
|
|
||||||
_: Electron.IpcMainInvokeEvent,
|
|
||||||
options: OpenDialogOptions
|
|
||||||
): Promise<{ fileName: string; content: Buffer } | null> {
|
|
||||||
try {
|
|
||||||
const result: OpenDialogReturnValue = await dialog.showOpenDialog({
|
|
||||||
title: '打开文件',
|
|
||||||
properties: ['openFile'],
|
|
||||||
filters: [{ name: '所有文件', extensions: ['*'] }],
|
|
||||||
...options
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result.canceled && result.filePaths.length > 0) {
|
|
||||||
const filePath = result.filePaths[0]
|
|
||||||
const fileName = filePath.split('/').pop() || ''
|
|
||||||
const content = await readFile(filePath)
|
|
||||||
return { fileName, content }
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('[IPC - Error]', 'An error occurred opening the file:', err)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getFileType(ext: string): FileTypes {
|
export function getFileType(ext: string): FileTypes {
|
||||||
const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
|
const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
|
||||||
const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv']
|
const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv']
|
||||||
|
|||||||
5
src/preload/index.d.ts
vendored
5
src/preload/index.d.ts
vendored
@ -14,8 +14,6 @@ declare global {
|
|||||||
checkForUpdate: () => void
|
checkForUpdate: () => void
|
||||||
openWebsite: (url: string) => void
|
openWebsite: (url: string) => void
|
||||||
setProxy: (proxy: string | undefined) => void
|
setProxy: (proxy: string | undefined) => void
|
||||||
saveFile: (path: string, content: string | NodeJS.ArrayBufferView, options?: SaveDialogOptions) => void
|
|
||||||
openFile: (options?: OpenDialogOptions) => Promise<{ fileName: string; content: Buffer } | null>
|
|
||||||
setTheme: (theme: 'light' | 'dark') => void
|
setTheme: (theme: 'light' | 'dark') => void
|
||||||
minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void
|
minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void
|
||||||
reload: () => void
|
reload: () => void
|
||||||
@ -31,6 +29,9 @@ declare global {
|
|||||||
get: (filePath: string) => Promise<FileType | null>
|
get: (filePath: string) => Promise<FileType | null>
|
||||||
create: (fileName: string) => Promise<string>
|
create: (fileName: string) => Promise<string>
|
||||||
write: (filePath: string, data: Uint8Array | string) => Promise<void>
|
write: (filePath: string, data: Uint8Array | string) => Promise<void>
|
||||||
|
open: (options?: OpenDialogOptions) => Promise<{ fileName: string; content: Buffer } | null>
|
||||||
|
save: (path: string, content: string | NodeJS.ArrayBufferView, options?: SaveDialogOptions) => void
|
||||||
|
saveImage: (name: string, data: string) => void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,11 +9,7 @@ const api = {
|
|||||||
setProxy: (proxy: string) => ipcRenderer.invoke('set-proxy', proxy),
|
setProxy: (proxy: string) => ipcRenderer.invoke('set-proxy', proxy),
|
||||||
setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('set-theme', theme),
|
setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('set-theme', theme),
|
||||||
minApp: (url: string) => ipcRenderer.invoke('minapp', url),
|
minApp: (url: string) => ipcRenderer.invoke('minapp', url),
|
||||||
openFile: (options?: { decompress: boolean }) => ipcRenderer.invoke('open-file', options),
|
|
||||||
reload: () => ipcRenderer.invoke('reload'),
|
reload: () => ipcRenderer.invoke('reload'),
|
||||||
saveFile: (path: string, content: string, options?: { compress: boolean }) => {
|
|
||||||
return ipcRenderer.invoke('save-file', path, content, options)
|
|
||||||
},
|
|
||||||
compress: (text: string) => ipcRenderer.invoke('zip:compress', text),
|
compress: (text: string) => ipcRenderer.invoke('zip:compress', text),
|
||||||
decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text),
|
decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text),
|
||||||
file: {
|
file: {
|
||||||
@ -25,7 +21,12 @@ const api = {
|
|||||||
clear: () => ipcRenderer.invoke('file:clear'),
|
clear: () => ipcRenderer.invoke('file:clear'),
|
||||||
get: (filePath: string) => ipcRenderer.invoke('file:get', filePath),
|
get: (filePath: string) => ipcRenderer.invoke('file:get', filePath),
|
||||||
create: (fileName: string) => ipcRenderer.invoke('file:create', fileName),
|
create: (fileName: string) => ipcRenderer.invoke('file:create', fileName),
|
||||||
write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke('file:write', filePath, data)
|
write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke('file:write', filePath, data),
|
||||||
|
open: (options?: { decompress: boolean }) => ipcRenderer.invoke('file:open', options),
|
||||||
|
save: (path: string, content: string, options?: { compress: boolean }) => {
|
||||||
|
return ipcRenderer.invoke('file:save', path, content, options)
|
||||||
|
},
|
||||||
|
saveImage: (name: string, data: string) => ipcRenderer.invoke('file:saveImage', name, data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { PlusOutlined, SearchOutlined } from '@ant-design/icons'
|
import { SearchOutlined } from '@ant-design/icons'
|
||||||
import { TopView } from '@renderer/components/TopView'
|
import { TopView } from '@renderer/components/TopView'
|
||||||
import systemAgents from '@renderer/config/agents.json'
|
import systemAgents from '@renderer/config/agents.json'
|
||||||
import { useAgents } from '@renderer/hooks/useAgents'
|
import { useAgents } from '@renderer/hooks/useAgents'
|
||||||
@ -110,7 +110,6 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
|||||||
onClick={() => onCreateAssistant(agent)}
|
onClick={() => onCreateAssistant(agent)}
|
||||||
className={agent.id === 'default' ? 'default' : ''}>
|
className={agent.id === 'default' ? 'default' : ''}>
|
||||||
<HStack alignItems="center" gap={5}>
|
<HStack alignItems="center" gap={5}>
|
||||||
{agent.id === 'default' && <PlusOutlined style={{ marginLeft: -2 }} />}
|
|
||||||
{agent.emoji} {agent.name}
|
{agent.emoji} {agent.name}
|
||||||
</HStack>
|
</HStack>
|
||||||
{agent.group === 'system' && <Tag color="green">{t('agents.tag.system')}</Tag>}
|
{agent.group === 'system' && <Tag color="green">{t('agents.tag.system')}</Tag>}
|
||||||
|
|||||||
@ -119,8 +119,8 @@ const Menus = styled.div`
|
|||||||
`
|
`
|
||||||
|
|
||||||
const Icon = styled.div`
|
const Icon = styled.div`
|
||||||
width: 34px;
|
width: 35px;
|
||||||
height: 34px;
|
height: 35px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -77,6 +77,8 @@ const resources = {
|
|||||||
'topics.delete.all.content': 'Are you sure you want to delete all topics?',
|
'topics.delete.all.content': 'Are you sure you want to delete all topics?',
|
||||||
'topics.move_to': 'Move to',
|
'topics.move_to': 'Move to',
|
||||||
'topics.list': 'Topic List',
|
'topics.list': 'Topic List',
|
||||||
|
'topics.export.title': 'Export',
|
||||||
|
'topics.export.image': 'Export as image',
|
||||||
'input.new_topic': 'New Topic',
|
'input.new_topic': 'New Topic',
|
||||||
'input.topics': ' Topics ',
|
'input.topics': ' Topics ',
|
||||||
'input.clear': 'Clear',
|
'input.clear': 'Clear',
|
||||||
@ -355,6 +357,8 @@ const resources = {
|
|||||||
'topics.delete.all.content': '确定要删除所有话题吗?',
|
'topics.delete.all.content': '确定要删除所有话题吗?',
|
||||||
'topics.move_to': '移动到',
|
'topics.move_to': '移动到',
|
||||||
'topics.list': '话题列表',
|
'topics.list': '话题列表',
|
||||||
|
'topics.export.title': '导出',
|
||||||
|
'topics.export.image': '导出为图片',
|
||||||
'input.new_topic': '新话题',
|
'input.new_topic': '新话题',
|
||||||
'input.topics': ' 话题 ',
|
'input.topics': ' 话题 ',
|
||||||
'input.clear': '清除会话消息',
|
'input.clear': '清除会话消息',
|
||||||
|
|||||||
@ -65,7 +65,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
|||||||
icon: <SaveOutlined />,
|
icon: <SaveOutlined />,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
const fileName = message.createdAt + '.md'
|
const fileName = message.createdAt + '.md'
|
||||||
window.api.saveFile(fileName, message.content)
|
window.api.file.save(fileName, message.content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
|||||||
import { deleteMessageFiles, filterMessages, getContextCount } from '@renderer/services/messages'
|
import { deleteMessageFiles, filterMessages, getContextCount } from '@renderer/services/messages'
|
||||||
import { estimateHistoryTokens, estimateMessageUsage } from '@renderer/services/tokens'
|
import { estimateHistoryTokens, estimateMessageUsage } from '@renderer/services/tokens'
|
||||||
import { Assistant, Message, Model, Topic } from '@renderer/types'
|
import { Assistant, Message, Model, Topic } from '@renderer/types'
|
||||||
import { getBriefInfo, runAsyncFunction, uuid } from '@renderer/utils'
|
import { captureScrollableDiv, getBriefInfo, runAsyncFunction, uuid } from '@renderer/utils'
|
||||||
import { t } from 'i18next'
|
import { t } from 'i18next'
|
||||||
import { flatten, last, reverse, take } from 'lodash'
|
import { flatten, last, reverse, take } from 'lodash'
|
||||||
import { FC, useCallback, useEffect, useRef, useState } from 'react'
|
import { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
@ -104,6 +104,12 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
|
|||||||
updateTopic({ ...topic, messages: [] })
|
updateTopic({ ...topic, messages: [] })
|
||||||
TopicManager.clearTopicMessages(topic.id)
|
TopicManager.clearTopicMessages(topic.id)
|
||||||
}),
|
}),
|
||||||
|
EventEmitter.on(EVENT_NAMES.EXPORT_TOPIC_IMAGE, async () => {
|
||||||
|
const imageData = await captureScrollableDiv(containerRef)
|
||||||
|
if (imageData) {
|
||||||
|
window.api.file.saveImage(topic.name, imageData)
|
||||||
|
}
|
||||||
|
}),
|
||||||
EventEmitter.on(EVENT_NAMES.NEW_CONTEXT, () => {
|
EventEmitter.on(EVENT_NAMES.NEW_CONTEXT, () => {
|
||||||
const lastMessage = last(messages)
|
const lastMessage = last(messages)
|
||||||
|
|
||||||
@ -200,6 +206,7 @@ const Container = styled.div`
|
|||||||
flex-direction: column-reverse;
|
flex-direction: column-reverse;
|
||||||
max-height: calc(100vh - var(--input-bar-height) - var(--navbar-height));
|
max-height: calc(100vh - var(--input-bar-height) - var(--navbar-height));
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
|
background-color: var(--color-background);
|
||||||
`
|
`
|
||||||
|
|
||||||
export default Messages
|
export default Messages
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { CloseOutlined, DeleteOutlined, EditOutlined, FolderOutlined } from '@ant-design/icons'
|
import { CloseOutlined, DeleteOutlined, EditOutlined, FolderOutlined, UploadOutlined } from '@ant-design/icons'
|
||||||
import DragableList from '@renderer/components/DragableList'
|
import DragableList from '@renderer/components/DragableList'
|
||||||
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
||||||
import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant'
|
import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant'
|
||||||
import { TopicManager } from '@renderer/hooks/useTopic'
|
import { TopicManager } from '@renderer/hooks/useTopic'
|
||||||
import { fetchMessagesSummary } from '@renderer/services/api'
|
import { fetchMessagesSummary } from '@renderer/services/api'
|
||||||
|
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||||
import { useAppSelector } from '@renderer/store'
|
import { useAppSelector } from '@renderer/store'
|
||||||
import { Assistant, Topic } from '@renderer/types'
|
import { Assistant, Topic } from '@renderer/types'
|
||||||
import { Dropdown, MenuProps } from 'antd'
|
import { Dropdown, MenuProps } from 'antd'
|
||||||
@ -94,6 +95,18 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
|||||||
updateTopic({ ...topic, name })
|
updateTopic({ ...topic, name })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('chat.topics.export.title'),
|
||||||
|
key: 'export',
|
||||||
|
icon: <UploadOutlined />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: t('chat.topics.export.image'),
|
||||||
|
key: 'image',
|
||||||
|
onClick: () => EventEmitter.emit(EVENT_NAMES.EXPORT_TOPIC_IMAGE, topic)
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -118,6 +118,7 @@ const MenuItem = styled.li`
|
|||||||
}
|
}
|
||||||
.iconfont {
|
.iconfont {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
|
line-height: 18px;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
margin-left: -1px;
|
margin-left: -1px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,13 +18,13 @@ export async function backup() {
|
|||||||
const fileContnet = JSON.stringify(data)
|
const fileContnet = JSON.stringify(data)
|
||||||
const file = await window.api.compress(fileContnet)
|
const file = await window.api.compress(fileContnet)
|
||||||
|
|
||||||
await window.api.saveFile(filename, file)
|
await window.api.file.save(filename, file)
|
||||||
|
|
||||||
window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' })
|
window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restore() {
|
export async function restore() {
|
||||||
const file = await window.api.openFile()
|
const file = await window.api.file.open()
|
||||||
|
|
||||||
if (file) {
|
if (file) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -17,5 +17,6 @@ export const EVENT_NAMES = {
|
|||||||
SHOW_TOPIC_SIDEBAR: 'SHOW_TOPIC_SIDEBAR',
|
SHOW_TOPIC_SIDEBAR: 'SHOW_TOPIC_SIDEBAR',
|
||||||
SWITCH_TOPIC_SIDEBAR: 'SWITCH_TOPIC_SIDEBAR',
|
SWITCH_TOPIC_SIDEBAR: 'SWITCH_TOPIC_SIDEBAR',
|
||||||
NEW_CONTEXT: 'NEW_CONTEXT',
|
NEW_CONTEXT: 'NEW_CONTEXT',
|
||||||
NEW_BRANCH: 'NEW_BRANCH'
|
NEW_BRANCH: 'NEW_BRANCH',
|
||||||
|
EXPORT_TOPIC_IMAGE: 'EXPORT_TOPIC_IMAGE'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Model } from '@renderer/types'
|
import { Model } from '@renderer/types'
|
||||||
import imageCompression from 'browser-image-compression'
|
import imageCompression from 'browser-image-compression'
|
||||||
|
import html2canvas from 'html2canvas'
|
||||||
// @ts-ignore next-line`
|
// @ts-ignore next-line`
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
@ -241,3 +242,66 @@ export function getFileExtension(filePath: string) {
|
|||||||
const extension = parts.slice(-1)[0]
|
const extension = parts.slice(-1)[0]
|
||||||
return '.' + extension
|
return '.' + extension
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function captureDiv(divRef: React.RefObject<HTMLDivElement>) {
|
||||||
|
if (divRef.current) {
|
||||||
|
try {
|
||||||
|
const canvas = await html2canvas(divRef.current)
|
||||||
|
const imageData = canvas.toDataURL('image/png')
|
||||||
|
return imageData
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error capturing div:', error)
|
||||||
|
return Promise.reject()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.resolve(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const captureScrollableDiv = async (divRef: React.RefObject<HTMLDivElement>) => {
|
||||||
|
if (divRef.current) {
|
||||||
|
try {
|
||||||
|
const div = divRef.current
|
||||||
|
|
||||||
|
// 保存原始样式
|
||||||
|
const originalStyle = {
|
||||||
|
height: div.style.height,
|
||||||
|
maxHeight: div.style.maxHeight,
|
||||||
|
overflow: div.style.overflow,
|
||||||
|
position: div.style.position
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalScrollTop = div.scrollTop
|
||||||
|
|
||||||
|
// 修改样式以显示全部内容
|
||||||
|
div.style.height = 'auto'
|
||||||
|
div.style.maxHeight = 'none'
|
||||||
|
div.style.overflow = 'visible'
|
||||||
|
div.style.position = 'static'
|
||||||
|
|
||||||
|
// 捕获整个内容
|
||||||
|
const canvas = await html2canvas(div, {
|
||||||
|
scrollY: -window.scrollY,
|
||||||
|
windowHeight: document.documentElement.scrollHeight
|
||||||
|
})
|
||||||
|
|
||||||
|
// 恢复原始样式
|
||||||
|
div.style.height = originalStyle.height
|
||||||
|
div.style.maxHeight = originalStyle.maxHeight
|
||||||
|
div.style.overflow = originalStyle.overflow
|
||||||
|
div.style.position = originalStyle.position
|
||||||
|
|
||||||
|
const imageData = canvas.toDataURL('image/png')
|
||||||
|
|
||||||
|
// 恢复原始滚动位置
|
||||||
|
setTimeout(() => {
|
||||||
|
div.scrollTop = originalScrollTop
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
return imageData
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error capturing scrollable div:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(undefined)
|
||||||
|
}
|
||||||
|
|||||||
45
yarn.lock
45
yarn.lock
@ -1785,6 +1785,7 @@ __metadata:
|
|||||||
eslint-plugin-simple-import-sort: "npm:^12.1.1"
|
eslint-plugin-simple-import-sort: "npm:^12.1.1"
|
||||||
eslint-plugin-unused-imports: "npm:^4.0.0"
|
eslint-plugin-unused-imports: "npm:^4.0.0"
|
||||||
gpt-tokens: "npm:^1.3.10"
|
gpt-tokens: "npm:^1.3.10"
|
||||||
|
html2canvas: "npm:^1.4.1"
|
||||||
i18next: "npm:^23.11.5"
|
i18next: "npm:^23.11.5"
|
||||||
localforage: "npm:^1.10.0"
|
localforage: "npm:^1.10.0"
|
||||||
lodash: "npm:^4.17.21"
|
lodash: "npm:^4.17.21"
|
||||||
@ -2290,6 +2291,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"base64-arraybuffer@npm:^1.0.2":
|
||||||
|
version: 1.0.2
|
||||||
|
resolution: "base64-arraybuffer@npm:1.0.2"
|
||||||
|
checksum: 10c0/3acac95c70f9406e87a41073558ba85b6be9dbffb013a3d2a710e3f2d534d506c911847d5d9be4de458af6362c676de0a5c4c2d7bdf4def502d00b313368e72f
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"base64-js@npm:^1.3.1, base64-js@npm:^1.5.1":
|
"base64-js@npm:^1.3.1, base64-js@npm:^1.5.1":
|
||||||
version: 1.5.1
|
version: 1.5.1
|
||||||
resolution: "base64-js@npm:1.5.1"
|
resolution: "base64-js@npm:1.5.1"
|
||||||
@ -2881,6 +2889,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"css-line-break@npm:^2.1.0":
|
||||||
|
version: 2.1.0
|
||||||
|
resolution: "css-line-break@npm:2.1.0"
|
||||||
|
dependencies:
|
||||||
|
utrie: "npm:^1.0.2"
|
||||||
|
checksum: 10c0/b2222d99d5daf7861ecddc050244fdce296fad74b000dcff6bdfb1eb16dc2ef0b9ffe2c1c965e3239bd05ebe9eadb6d5438a91592fa8648d27a338e827cf9048
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"css-to-react-native@npm:3.2.0":
|
"css-to-react-native@npm:3.2.0":
|
||||||
version: 3.2.0
|
version: 3.2.0
|
||||||
resolution: "css-to-react-native@npm:3.2.0"
|
resolution: "css-to-react-native@npm:3.2.0"
|
||||||
@ -4669,6 +4686,16 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"html2canvas@npm:^1.4.1":
|
||||||
|
version: 1.4.1
|
||||||
|
resolution: "html2canvas@npm:1.4.1"
|
||||||
|
dependencies:
|
||||||
|
css-line-break: "npm:^2.1.0"
|
||||||
|
text-segmentation: "npm:^1.0.3"
|
||||||
|
checksum: 10c0/6de86f75762b00948edf2ea559f16da0a1ec3facc4a8a7d3f35fcec59bb0c5970463478988ae3d9082152e0173690d46ebf4082e7ac803dd4817bae1d355c0db
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"http-cache-semantics@npm:^4.0.0, http-cache-semantics@npm:^4.1.1":
|
"http-cache-semantics@npm:^4.0.0, http-cache-semantics@npm:^4.1.1":
|
||||||
version: 4.1.1
|
version: 4.1.1
|
||||||
resolution: "http-cache-semantics@npm:4.1.1"
|
resolution: "http-cache-semantics@npm:4.1.1"
|
||||||
@ -8750,6 +8777,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"text-segmentation@npm:^1.0.3":
|
||||||
|
version: 1.0.3
|
||||||
|
resolution: "text-segmentation@npm:1.0.3"
|
||||||
|
dependencies:
|
||||||
|
utrie: "npm:^1.0.2"
|
||||||
|
checksum: 10c0/8b9ae8524e3a332371060d0ca62f10ad49a13e954719ea689a6c3a8b8c15c8a56365ede2bb91c322fb0d44b6533785f0da603e066b7554d052999967fb72d600
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"text-table@npm:^0.2.0":
|
"text-table@npm:^0.2.0":
|
||||||
version: 0.2.0
|
version: 0.2.0
|
||||||
resolution: "text-table@npm:0.2.0"
|
resolution: "text-table@npm:0.2.0"
|
||||||
@ -9179,6 +9215,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"utrie@npm:^1.0.2":
|
||||||
|
version: 1.0.2
|
||||||
|
resolution: "utrie@npm:1.0.2"
|
||||||
|
dependencies:
|
||||||
|
base64-arraybuffer: "npm:^1.0.2"
|
||||||
|
checksum: 10c0/eaffe645bd81a39e4bc3abb23df5895e9961dbdd49748ef3b173529e8b06ce9dd1163e9705d5309a1c61ee41ffcb825e2043bc0fd1659845ffbdf4b1515dfdb4
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"uuid@npm:^10.0.0":
|
"uuid@npm:^10.0.0":
|
||||||
version: 10.0.0
|
version: 10.0.0
|
||||||
resolution: "uuid@npm:10.0.0"
|
resolution: "uuid@npm:10.0.0"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user