Compare commits
10 Commits
main
...
feat/print
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b14546462 | ||
|
|
1ec308f9a3 | ||
|
|
bb76f83367 | ||
|
|
d823bd6471 | ||
|
|
e9dd069b2b | ||
|
|
b248a6932e | ||
|
|
5bb78a3142 | ||
|
|
a17d62f3ec | ||
|
|
60e69ad49c | ||
|
|
009e0d0877 |
@@ -11,6 +11,13 @@ electronLanguages:
|
|||||||
- en # for macOS
|
- en # for macOS
|
||||||
directories:
|
directories:
|
||||||
buildResources: build
|
buildResources: build
|
||||||
|
extraResources:
|
||||||
|
- from: 'src/renderer/src/assets/styles'
|
||||||
|
to: 'app.asar.unpacked/resources/styles'
|
||||||
|
filter:
|
||||||
|
- 'font.css'
|
||||||
|
- 'color.css'
|
||||||
|
- 'richtext.css'
|
||||||
|
|
||||||
protocols:
|
protocols:
|
||||||
- name: Cherry Studio
|
- name: Cherry Studio
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ export enum IpcChannel {
|
|||||||
FileService_Retrieve = 'file-service:retrieve',
|
FileService_Retrieve = 'file-service:retrieve',
|
||||||
|
|
||||||
Export_Word = 'export:word',
|
Export_Word = 'export:word',
|
||||||
|
Export_PDF = 'export:pdf',
|
||||||
|
|
||||||
Shortcuts_Update = 'shortcuts:update',
|
Shortcuts_Update = 'shortcuts:update',
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,32 @@ export const isWin = process.platform === 'win32'
|
|||||||
export const isLinux = process.platform === 'linux'
|
export const isLinux = process.platform === 'linux'
|
||||||
export const isDev = process.env.NODE_ENV === 'development'
|
export const isDev = process.env.NODE_ENV === 'development'
|
||||||
export const isPortable = isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env
|
export const isPortable = isWin && 'PORTABLE_EXECUTABLE_DIR' in process.env
|
||||||
|
|
||||||
|
export const PRINT_HTML_TEMPLATE = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>{{filename}}</title>
|
||||||
|
<style>
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Color variables */
|
||||||
|
{{colorCss}}
|
||||||
|
/* Font variables */
|
||||||
|
{{fontCss}}
|
||||||
|
/* Richtext styles */
|
||||||
|
{{richtextCss}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body theme-mode="light" os=${isMac ? 'mac' : isWin ? 'windows' : 'linux'}>
|
||||||
|
<div id="root">
|
||||||
|
<div class="tiptap">{{content}}</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|||||||
@@ -524,6 +524,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
// export
|
// export
|
||||||
ipcMain.handle(IpcChannel.Export_Word, exportService.exportToWord.bind(exportService))
|
ipcMain.handle(IpcChannel.Export_Word, exportService.exportToWord.bind(exportService))
|
||||||
|
|
||||||
|
// PDF export
|
||||||
|
ipcMain.handle(IpcChannel.Export_PDF, exportService.exportToPDF.bind(exportService))
|
||||||
|
|
||||||
// open path
|
// open path
|
||||||
ipcMain.handle(IpcChannel.Open_Path, async (_, path: string) => {
|
ipcMain.handle(IpcChannel.Open_Path, async (_, path: string) => {
|
||||||
await shell.openPath(path)
|
await shell.openPath(path)
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
/* oxlint-disable no-case-declarations */
|
/* oxlint-disable no-case-declarations */
|
||||||
// ExportService
|
// ExportService
|
||||||
|
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
|
import { PRINT_HTML_TEMPLATE } from '@main/constant'
|
||||||
import {
|
import {
|
||||||
AlignmentType,
|
AlignmentType,
|
||||||
BorderStyle,
|
BorderStyle,
|
||||||
@@ -18,10 +23,11 @@ import {
|
|||||||
VerticalAlign,
|
VerticalAlign,
|
||||||
WidthType
|
WidthType
|
||||||
} from 'docx'
|
} from 'docx'
|
||||||
import { dialog } from 'electron'
|
import { app, BrowserWindow, dialog } from 'electron'
|
||||||
import MarkdownIt from 'markdown-it'
|
import MarkdownIt from 'markdown-it'
|
||||||
|
|
||||||
import { fileStorage } from './FileStorage'
|
import { fileStorage } from './FileStorage'
|
||||||
|
import { windowService } from './WindowService'
|
||||||
|
|
||||||
const logger = loggerService.withContext('ExportService')
|
const logger = loggerService.withContext('ExportService')
|
||||||
export class ExportService {
|
export class ExportService {
|
||||||
@@ -405,4 +411,116 @@ export class ExportService {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public exportToPDF = async (_: Electron.IpcMainInvokeEvent, content: string, filename: string): Promise<any> => {
|
||||||
|
const mainWindow = windowService.getMainWindow()
|
||||||
|
if (!mainWindow) {
|
||||||
|
throw new Error('Main window not set')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loadCssFile = async (filename: string): Promise<string> => {
|
||||||
|
try {
|
||||||
|
let cssPath: string
|
||||||
|
if (app.isPackaged) {
|
||||||
|
cssPath = path.join(process.resourcesPath, 'app.asar.unpacked', 'resources', 'styles', filename)
|
||||||
|
} else {
|
||||||
|
cssPath = path.join(app.getAppPath(), 'src', 'renderer', 'src', 'assets', 'styles', filename)
|
||||||
|
}
|
||||||
|
return await fs.promises.readFile(cssPath, 'utf-8')
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Could not load ${filename}, using fallback:`, error as Error)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorCss = await loadCssFile('color.css')
|
||||||
|
const fontCss = await loadCssFile('font.css')
|
||||||
|
const richtextCss = await loadCssFile('richtext.css')
|
||||||
|
|
||||||
|
// PDF专用样式,解决代码块溢出问题
|
||||||
|
const pdfSpecificCss = `
|
||||||
|
@media print {
|
||||||
|
.tiptap pre {
|
||||||
|
white-space: pre-wrap !important;
|
||||||
|
word-wrap: break-word !important;
|
||||||
|
overflow-wrap: break-word !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap pre code {
|
||||||
|
white-space: pre-wrap !important;
|
||||||
|
word-wrap: break-word !important;
|
||||||
|
overflow-wrap: break-word !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap {
|
||||||
|
max-width: 100% !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const tempHtmlPath = path.join(os.tmpdir(), `temp_${Date.now()}.html`)
|
||||||
|
await fs.promises.writeFile(
|
||||||
|
tempHtmlPath,
|
||||||
|
PRINT_HTML_TEMPLATE.replace('{{filename}}', filename.replace('.pdf', ''))
|
||||||
|
.replace('{{colorCss}}', colorCss)
|
||||||
|
.replace('{{richtextCss}}', richtextCss + pdfSpecificCss)
|
||||||
|
.replace('{{fontCss}}', fontCss)
|
||||||
|
.replace('{{content}}', content)
|
||||||
|
)
|
||||||
|
|
||||||
|
const printWindow = new BrowserWindow({
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
show: false,
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await printWindow.loadFile(tempHtmlPath)
|
||||||
|
|
||||||
|
// Show save dialog for PDF
|
||||||
|
const result = await dialog.showSaveDialog(mainWindow, {
|
||||||
|
defaultPath: filename,
|
||||||
|
filters: [{ name: 'PDF Files', extensions: ['pdf'] }]
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.canceled || !result.filePath) {
|
||||||
|
printWindow.close()
|
||||||
|
await fs.promises.unlink(tempHtmlPath)
|
||||||
|
return { success: false, message: 'Export cancelled' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate PDF using printToPDF for vector output
|
||||||
|
const pdfData = await printWindow.webContents.printToPDF({
|
||||||
|
margins: {
|
||||||
|
top: 0.5,
|
||||||
|
bottom: 0.5,
|
||||||
|
left: 0.5,
|
||||||
|
right: 0.5
|
||||||
|
},
|
||||||
|
pageSize: 'A4',
|
||||||
|
printBackground: true,
|
||||||
|
scale: 1.0,
|
||||||
|
preferCSSPageSize: false,
|
||||||
|
landscape: false
|
||||||
|
})
|
||||||
|
|
||||||
|
await fs.promises.writeFile(result.filePath, pdfData)
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
printWindow.close()
|
||||||
|
await fs.promises.unlink(tempHtmlPath)
|
||||||
|
|
||||||
|
return { success: true, filePath: result.filePath }
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Failed to export PDF:', error)
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,7 +205,8 @@ const api = {
|
|||||||
readText: (pathOrUrl: string): Promise<string> => ipcRenderer.invoke(IpcChannel.Fs_ReadText, pathOrUrl)
|
readText: (pathOrUrl: string): Promise<string> => ipcRenderer.invoke(IpcChannel.Fs_ReadText, pathOrUrl)
|
||||||
},
|
},
|
||||||
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),
|
||||||
|
toPDF: (content: string, fileName: string) => ipcRenderer.invoke(IpcChannel.Export_PDF, content, fileName)
|
||||||
},
|
},
|
||||||
obsidian: {
|
obsidian: {
|
||||||
getVaults: () => ipcRenderer.invoke(IpcChannel.Obsidian_GetVaults),
|
getVaults: () => ipcRenderer.invoke(IpcChannel.Obsidian_GetVaults),
|
||||||
|
|||||||
@@ -1385,6 +1385,9 @@
|
|||||||
"no_api_key": "Notion ApiKey or Notion DatabaseID is not configured",
|
"no_api_key": "Notion ApiKey or Notion DatabaseID is not configured",
|
||||||
"no_content": "There is nothing to export to Notion."
|
"no_content": "There is nothing to export to Notion."
|
||||||
},
|
},
|
||||||
|
"pdf": {
|
||||||
|
"export": "Failed to export to PDF"
|
||||||
|
},
|
||||||
"siyuan": {
|
"siyuan": {
|
||||||
"export": "Failed to export to Siyuan Note, please check connection status and configuration according to documentation",
|
"export": "Failed to export to Siyuan Note, please check connection status and configuration according to documentation",
|
||||||
"no_config": "Siyuan Note API address or token is not configured"
|
"no_config": "Siyuan Note API address or token is not configured"
|
||||||
@@ -1487,6 +1490,9 @@
|
|||||||
"notion": {
|
"notion": {
|
||||||
"export": "Successfully exported to Notion"
|
"export": "Successfully exported to Notion"
|
||||||
},
|
},
|
||||||
|
"pdf": {
|
||||||
|
"export": "Successfully exported to PDF"
|
||||||
|
},
|
||||||
"siyuan": {
|
"siyuan": {
|
||||||
"export": "Successfully exported to Siyuan Note"
|
"export": "Successfully exported to Siyuan Note"
|
||||||
},
|
},
|
||||||
@@ -1707,6 +1713,7 @@
|
|||||||
"drop_markdown_hint": "Drop .md files or folders here to import",
|
"drop_markdown_hint": "Drop .md files or folders here to import",
|
||||||
"empty": "No notes available yet",
|
"empty": "No notes available yet",
|
||||||
"expand": "unfold",
|
"expand": "unfold",
|
||||||
|
"exportPDF": "Export to PDF",
|
||||||
"export_failed": "Failed to export to knowledge base",
|
"export_failed": "Failed to export to knowledge base",
|
||||||
"export_knowledge": "Export notes to knowledge base",
|
"export_knowledge": "Export notes to knowledge base",
|
||||||
"export_success": "Successfully exported to the knowledge base",
|
"export_success": "Successfully exported to the knowledge base",
|
||||||
|
|||||||
@@ -1385,6 +1385,9 @@
|
|||||||
"no_api_key": "未配置 Notion API Key 或 Notion Database ID",
|
"no_api_key": "未配置 Notion API Key 或 Notion Database ID",
|
||||||
"no_content": "无可导出到 Notion 的内容"
|
"no_content": "无可导出到 Notion 的内容"
|
||||||
},
|
},
|
||||||
|
"pdf": {
|
||||||
|
"export": "导出 PDF 失败"
|
||||||
|
},
|
||||||
"siyuan": {
|
"siyuan": {
|
||||||
"export": "导出思源笔记失败,请检查连接状态并对照文档检查配置",
|
"export": "导出思源笔记失败,请检查连接状态并对照文档检查配置",
|
||||||
"no_config": "未配置思源笔记 API 地址或令牌"
|
"no_config": "未配置思源笔记 API 地址或令牌"
|
||||||
@@ -1487,6 +1490,9 @@
|
|||||||
"notion": {
|
"notion": {
|
||||||
"export": "成功导出到 Notion"
|
"export": "成功导出到 Notion"
|
||||||
},
|
},
|
||||||
|
"pdf": {
|
||||||
|
"export": "成功导出为 PDF"
|
||||||
|
},
|
||||||
"siyuan": {
|
"siyuan": {
|
||||||
"export": "导出到思源笔记成功"
|
"export": "导出到思源笔记成功"
|
||||||
},
|
},
|
||||||
@@ -1707,6 +1713,7 @@
|
|||||||
"drop_markdown_hint": "拖拽 .md 文件或目录到此处导入",
|
"drop_markdown_hint": "拖拽 .md 文件或目录到此处导入",
|
||||||
"empty": "暂无笔记",
|
"empty": "暂无笔记",
|
||||||
"expand": "展开",
|
"expand": "展开",
|
||||||
|
"exportPDF": "导出为PDF",
|
||||||
"export_failed": "导出到知识库失败",
|
"export_failed": "导出到知识库失败",
|
||||||
"export_knowledge": "导出笔记到知识库",
|
"export_knowledge": "导出笔记到知识库",
|
||||||
"export_success": "成功导出到知识库",
|
"export_success": "成功导出到知识库",
|
||||||
|
|||||||
@@ -1385,6 +1385,9 @@
|
|||||||
"no_api_key": "未設定 Notion API Key 或 Notion Database ID",
|
"no_api_key": "未設定 Notion API Key 或 Notion Database ID",
|
||||||
"no_content": "沒有可匯出至 Notion 的內容"
|
"no_content": "沒有可匯出至 Notion 的內容"
|
||||||
},
|
},
|
||||||
|
"pdf": {
|
||||||
|
"export": "導出 PDF 失敗"
|
||||||
|
},
|
||||||
"siyuan": {
|
"siyuan": {
|
||||||
"export": "導出思源筆記失敗,請檢查連接狀態並對照文檔檢查配置",
|
"export": "導出思源筆記失敗,請檢查連接狀態並對照文檔檢查配置",
|
||||||
"no_config": "未配置思源筆記 API 地址或令牌"
|
"no_config": "未配置思源筆記 API 地址或令牌"
|
||||||
@@ -1487,6 +1490,9 @@
|
|||||||
"notion": {
|
"notion": {
|
||||||
"export": "成功匯出到 Notion"
|
"export": "成功匯出到 Notion"
|
||||||
},
|
},
|
||||||
|
"pdf": {
|
||||||
|
"export": "成功導出為 PDF"
|
||||||
|
},
|
||||||
"siyuan": {
|
"siyuan": {
|
||||||
"export": "導出到思源筆記成功"
|
"export": "導出到思源筆記成功"
|
||||||
},
|
},
|
||||||
@@ -1707,6 +1713,7 @@
|
|||||||
"drop_markdown_hint": "拖拽 .md 文件或資料夾到此處導入",
|
"drop_markdown_hint": "拖拽 .md 文件或資料夾到此處導入",
|
||||||
"empty": "暫無筆記",
|
"empty": "暫無筆記",
|
||||||
"expand": "展開",
|
"expand": "展開",
|
||||||
|
"exportPDF": "匯出為PDF",
|
||||||
"export_failed": "匯出至知識庫失敗",
|
"export_failed": "匯出至知識庫失敗",
|
||||||
"export_knowledge": "匯出筆記至知識庫",
|
"export_knowledge": "匯出筆記至知識庫",
|
||||||
"export_success": "成功匯出至知識庫",
|
"export_success": "成功匯出至知識庫",
|
||||||
|
|||||||
@@ -540,7 +540,7 @@
|
|||||||
"title": "コード実行"
|
"title": "コード実行"
|
||||||
},
|
},
|
||||||
"code_fancy_block": {
|
"code_fancy_block": {
|
||||||
"label": "<translate_input>\n装飾的なコードブロック\n</translate_input>",
|
"label": "装飾的なコードブロック",
|
||||||
"tip": "より見栄えの良いコードブロックスタイルを使用する、例えばHTMLカード"
|
"tip": "より見栄えの良いコードブロックスタイルを使用する、例えばHTMLカード"
|
||||||
},
|
},
|
||||||
"code_image_tools": {
|
"code_image_tools": {
|
||||||
|
|||||||
@@ -13,10 +13,19 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import { menuItems } from './MenuConfig'
|
import { menuItems } from './MenuConfig'
|
||||||
|
import { ExportContext, handleExportPDF } from './utils/exportUtils'
|
||||||
|
|
||||||
const logger = loggerService.withContext('HeaderNavbar')
|
const logger = loggerService.withContext('HeaderNavbar')
|
||||||
|
|
||||||
const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpandPath, onRenameNode }) => {
|
const HeaderNavbar = ({
|
||||||
|
notesTree,
|
||||||
|
editorRef,
|
||||||
|
currentContent,
|
||||||
|
getCurrentNoteContent,
|
||||||
|
onToggleStar,
|
||||||
|
onExpandPath,
|
||||||
|
onRenameNode,
|
||||||
|
}) => {
|
||||||
const { showWorkspace, toggleShowWorkspace } = useShowWorkspace()
|
const { showWorkspace, toggleShowWorkspace } = useShowWorkspace()
|
||||||
const { activeNode } = useActiveNode(notesTree)
|
const { activeNode } = useActiveNode(notesTree)
|
||||||
const [breadcrumbItems, setBreadcrumbItems] = useState<
|
const [breadcrumbItems, setBreadcrumbItems] = useState<
|
||||||
@@ -90,6 +99,15 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpand
|
|||||||
[activeNode]
|
[activeNode]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleExportPDFAction = useCallback(async () => {
|
||||||
|
const menuContext: ExportContext = {
|
||||||
|
editorRef,
|
||||||
|
currentContent,
|
||||||
|
fileName: activeNode?.name || t('notes.title')
|
||||||
|
}
|
||||||
|
await handleExportPDF(menuContext)
|
||||||
|
}, [editorRef, currentContent, activeNode])
|
||||||
|
|
||||||
const buildMenuItem = (item: any) => {
|
const buildMenuItem = (item: any) => {
|
||||||
if (item.type === 'divider') {
|
if (item.type === 'divider') {
|
||||||
return { type: 'divider' as const, key: item.key }
|
return { type: 'divider' as const, key: item.key }
|
||||||
@@ -131,6 +149,8 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpand
|
|||||||
onClick: () => {
|
onClick: () => {
|
||||||
if (item.copyAction) {
|
if (item.copyAction) {
|
||||||
handleCopyContent()
|
handleCopyContent()
|
||||||
|
} else if (item.exportPdfAction) {
|
||||||
|
handleExportPDFAction()
|
||||||
} else if (item.action) {
|
} else if (item.action) {
|
||||||
item.action(settings, updateSettings)
|
item.action(settings, updateSettings)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
import { NotesSettings } from '@renderer/store/note'
|
import { NotesSettings } from '@renderer/store/note'
|
||||||
import { Copy, MonitorSpeaker, Type } from 'lucide-react'
|
import { Copy, Download, MonitorSpeaker, Type } from 'lucide-react'
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { ExportContext } from './utils/exportUtils'
|
||||||
|
|
||||||
export interface MenuItem {
|
export interface MenuItem {
|
||||||
key: string
|
key: string
|
||||||
type?: 'divider' | 'component'
|
type?: 'divider' | 'component'
|
||||||
labelKey: string
|
labelKey: string
|
||||||
icon?: React.ComponentType<any>
|
icon?: React.ComponentType<any>
|
||||||
action?: (settings: NotesSettings, updateSettings: (newSettings: Partial<NotesSettings>) => void) => void
|
action?: (
|
||||||
|
settings: NotesSettings,
|
||||||
|
updateSettings: (newSettings: Partial<NotesSettings>) => void,
|
||||||
|
context?: ExportContext
|
||||||
|
) => void
|
||||||
children?: MenuItem[]
|
children?: MenuItem[]
|
||||||
isActive?: (settings: NotesSettings) => boolean
|
isActive?: (settings: NotesSettings) => boolean
|
||||||
component?: (settings: NotesSettings, updateSettings: (newSettings: Partial<NotesSettings>) => void) => ReactNode
|
component?: (settings: NotesSettings, updateSettings: (newSettings: Partial<NotesSettings>) => void) => ReactNode
|
||||||
copyAction?: boolean
|
copyAction?: boolean
|
||||||
|
exportPdfAction?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const menuItems: MenuItem[] = [
|
export const menuItems: MenuItem[] = [
|
||||||
@@ -21,6 +28,12 @@ export const menuItems: MenuItem[] = [
|
|||||||
icon: Copy,
|
icon: Copy,
|
||||||
copyAction: true
|
copyAction: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'export-pdf',
|
||||||
|
labelKey: 'notes.exportPDF',
|
||||||
|
icon: Download,
|
||||||
|
exportPdfAction: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'divider0',
|
key: 'divider0',
|
||||||
type: 'divider',
|
type: 'divider',
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import styled from 'styled-components'
|
|||||||
import HeaderNavbar from './HeaderNavbar'
|
import HeaderNavbar from './HeaderNavbar'
|
||||||
import NotesEditor from './NotesEditor'
|
import NotesEditor from './NotesEditor'
|
||||||
import NotesSidebar from './NotesSidebar'
|
import NotesSidebar from './NotesSidebar'
|
||||||
|
import { handleExportPDF } from './utils/exportUtils'
|
||||||
|
|
||||||
const logger = loggerService.withContext('NotesPage')
|
const logger = loggerService.withContext('NotesPage')
|
||||||
|
|
||||||
@@ -751,6 +752,50 @@ const NotesPage: FC = () => {
|
|||||||
}
|
}
|
||||||
}, [currentContent, settings.defaultEditMode])
|
}, [currentContent, settings.defaultEditMode])
|
||||||
|
|
||||||
|
// 处理PDF导出 - 复用MenuConfig中的导出逻辑
|
||||||
|
const handleExportToPDF = useCallback(
|
||||||
|
async (nodeId: string) => {
|
||||||
|
try {
|
||||||
|
const node = findNodeById(notesTree, nodeId)
|
||||||
|
if (!node || node.type !== 'file') {
|
||||||
|
logger.warn('Cannot export PDF: Invalid node', { nodeId })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是当前活动的笔记,直接使用当前编辑器
|
||||||
|
if (activeFilePath === node.externalPath && editorRef.current) {
|
||||||
|
const filename = node.name.endsWith('.md') ? node.name.replace('.md', '') : node.name
|
||||||
|
await handleExportPDF({
|
||||||
|
editorRef: editorRef as React.RefObject<RichEditorRef>,
|
||||||
|
currentContent: getCurrentNoteContent(),
|
||||||
|
fileName: filename
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果不是当前笔记,先切换到该笔记再导出
|
||||||
|
dispatch(setActiveFilePath(node.externalPath))
|
||||||
|
invalidateFileContent(node.externalPath)
|
||||||
|
|
||||||
|
// 等待内容加载后再导出
|
||||||
|
setTimeout(async () => {
|
||||||
|
if (editorRef.current) {
|
||||||
|
const filename = node.name.endsWith('.md') ? node.name.replace('.md', '') : node.name
|
||||||
|
await handleExportPDF({
|
||||||
|
editorRef: editorRef as React.RefObject<RichEditorRef>,
|
||||||
|
currentContent: editorRef.current.getMarkdown(),
|
||||||
|
fileName: filename
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to export PDF:', error as Error)
|
||||||
|
window.toast.error(`导出PDF失败: ${(error as Error).message}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[findNodeById, notesTree, activeFilePath, editorRef, getCurrentNoteContent, dispatch, invalidateFileContent]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container id="notes-page">
|
<Container id="notes-page">
|
||||||
<Navbar>
|
<Navbar>
|
||||||
@@ -778,6 +823,7 @@ const NotesPage: FC = () => {
|
|||||||
onMoveNode={handleMoveNode}
|
onMoveNode={handleMoveNode}
|
||||||
onSortNodes={handleSortNodes}
|
onSortNodes={handleSortNodes}
|
||||||
onUploadFiles={handleUploadFiles}
|
onUploadFiles={handleUploadFiles}
|
||||||
|
onExportToPDF={handleExportToPDF}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
@@ -785,6 +831,8 @@ const NotesPage: FC = () => {
|
|||||||
<EditorWrapper>
|
<EditorWrapper>
|
||||||
<HeaderNavbar
|
<HeaderNavbar
|
||||||
notesTree={notesTree}
|
notesTree={notesTree}
|
||||||
|
editorRef={editorRef}
|
||||||
|
currentContent={currentContent}
|
||||||
getCurrentNoteContent={getCurrentNoteContent}
|
getCurrentNoteContent={getCurrentNoteContent}
|
||||||
onToggleStar={handleToggleStar}
|
onToggleStar={handleToggleStar}
|
||||||
onExpandPath={handleExpandPath}
|
onExpandPath={handleExpandPath}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Dropdown, Input, InputRef, MenuProps } from 'antd'
|
|||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Download,
|
||||||
Edit3,
|
Edit3,
|
||||||
File,
|
File,
|
||||||
FilePlus,
|
FilePlus,
|
||||||
@@ -38,6 +39,7 @@ interface NotesSidebarProps {
|
|||||||
onMoveNode: (sourceNodeId: string, targetNodeId: string, position: 'before' | 'after' | 'inside') => void
|
onMoveNode: (sourceNodeId: string, targetNodeId: string, position: 'before' | 'after' | 'inside') => void
|
||||||
onSortNodes: (sortType: NotesSortType) => void
|
onSortNodes: (sortType: NotesSortType) => void
|
||||||
onUploadFiles: (files: File[]) => void
|
onUploadFiles: (files: File[]) => void
|
||||||
|
onExportToPDF?: (nodeId: string) => void
|
||||||
notesTree: NotesTreeNode[]
|
notesTree: NotesTreeNode[]
|
||||||
selectedFolderId?: string | null
|
selectedFolderId?: string | null
|
||||||
}
|
}
|
||||||
@@ -206,6 +208,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
onMoveNode,
|
onMoveNode,
|
||||||
onSortNodes,
|
onSortNodes,
|
||||||
onUploadFiles,
|
onUploadFiles,
|
||||||
|
onExportToPDF,
|
||||||
notesTree,
|
notesTree,
|
||||||
selectedFolderId
|
selectedFolderId
|
||||||
}) => {
|
}) => {
|
||||||
@@ -336,6 +339,15 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
[bases.length, t]
|
[bases.length, t]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleExportToPDF = useCallback(
|
||||||
|
(note: NotesTreeNode) => {
|
||||||
|
if (onExportToPDF && note.type === 'file') {
|
||||||
|
onExportToPDF(note.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onExportToPDF]
|
||||||
|
)
|
||||||
|
|
||||||
const handleDragStart = useCallback((e: React.DragEvent, node: NotesTreeNode) => {
|
const handleDragStart = useCallback((e: React.DragEvent, node: NotesTreeNode) => {
|
||||||
setDraggedNodeId(node.id)
|
setDraggedNodeId(node.id)
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
@@ -525,6 +537,14 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
onClick: () => {
|
onClick: () => {
|
||||||
handleExportKnowledge(node)
|
handleExportKnowledge(node)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('notes.exportPDF'),
|
||||||
|
key: 'export_pdf',
|
||||||
|
icon: <Download size={14} />,
|
||||||
|
onClick: () => {
|
||||||
|
handleExportToPDF(node)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -543,7 +563,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
|||||||
|
|
||||||
return baseMenuItems
|
return baseMenuItems
|
||||||
},
|
},
|
||||||
[t, handleStartEdit, onToggleStar, handleExportKnowledge, handleDeleteNode]
|
[t, handleStartEdit, onToggleStar, handleExportKnowledge, handleExportToPDF, handleDeleteNode]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDropFiles = useCallback(
|
const handleDropFiles = useCallback(
|
||||||
|
|||||||
123
src/renderer/src/pages/notes/utils/exportUtils.ts
Normal file
123
src/renderer/src/pages/notes/utils/exportUtils.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { RichEditorRef } from '@renderer/components/RichEditor/types'
|
||||||
|
import i18n from '@renderer/i18n'
|
||||||
|
import { getHighlighter } from '@renderer/utils/shiki'
|
||||||
|
import { RefObject } from 'react'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('exportUtils')
|
||||||
|
|
||||||
|
export interface ExportContext {
|
||||||
|
editorRef: RefObject<RichEditorRef>
|
||||||
|
currentContent: string
|
||||||
|
fileName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加语法高亮
|
||||||
|
* @param html 原始 HTML
|
||||||
|
* @returns 添加语法高亮后的 HTML
|
||||||
|
*/
|
||||||
|
const addSyntaxHighlighting = async (html: string): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const highlighter = await getHighlighter()
|
||||||
|
const parser = new DOMParser()
|
||||||
|
const doc = parser.parseFromString(html, 'text/html')
|
||||||
|
|
||||||
|
const codeBlocks = doc.querySelectorAll('pre code')
|
||||||
|
|
||||||
|
for (const codeElement of codeBlocks) {
|
||||||
|
const preElement = codeElement.parentElement as HTMLPreElement
|
||||||
|
const codeText = codeElement.textContent || ''
|
||||||
|
|
||||||
|
if (!codeText.trim()) continue
|
||||||
|
|
||||||
|
let languageMatch = preElement.className.match(/language-(\w+)/)
|
||||||
|
if (!languageMatch) {
|
||||||
|
languageMatch = codeElement.className.match(/language-(\w+)/)
|
||||||
|
}
|
||||||
|
const language = languageMatch ? languageMatch[1] : 'text'
|
||||||
|
|
||||||
|
// Skip highlighting for plain text
|
||||||
|
if (language === 'text' || language === 'plain' || language === 'plaintext') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loadedLanguages = highlighter.getLoadedLanguages()
|
||||||
|
|
||||||
|
if (loadedLanguages.includes(language)) {
|
||||||
|
const highlightedHtml = highlighter.codeToHtml(codeText, {
|
||||||
|
lang: language,
|
||||||
|
theme: 'one-light'
|
||||||
|
})
|
||||||
|
|
||||||
|
const tempDoc = parser.parseFromString(highlightedHtml, 'text/html')
|
||||||
|
const highlightedCode = tempDoc.querySelector('code')
|
||||||
|
|
||||||
|
if (highlightedCode) {
|
||||||
|
// 保留原有的类名和属性
|
||||||
|
const originalClasses = codeElement.className
|
||||||
|
const originalAttributes = Array.from(codeElement.attributes)
|
||||||
|
|
||||||
|
// 替换内容
|
||||||
|
codeElement.innerHTML = highlightedCode.innerHTML
|
||||||
|
|
||||||
|
// 合并类名
|
||||||
|
const highlightedClasses = highlightedCode.className
|
||||||
|
const mergedClasses = [originalClasses, highlightedClasses]
|
||||||
|
.filter((cls) => cls && cls.trim())
|
||||||
|
.join(' ')
|
||||||
|
.split(' ')
|
||||||
|
.filter((cls, index, arr) => cls && arr.indexOf(cls) === index) // 去重
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
if (mergedClasses) {
|
||||||
|
codeElement.className = mergedClasses
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留原有的其他属性(除了class)
|
||||||
|
originalAttributes.forEach((attr) => {
|
||||||
|
if (attr.name !== 'class' && !codeElement.hasAttribute(attr.name)) {
|
||||||
|
codeElement.setAttribute(attr.name, attr.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to highlight ${language} code block:`, error as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return doc.documentElement.outerHTML
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to add syntax highlighting, using original HTML:', error as Error)
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleExportPDF = async (context: ExportContext) => {
|
||||||
|
if (!context.editorRef?.current || !context.currentContent?.trim()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let htmlContent = context.editorRef.current.getHtml()
|
||||||
|
|
||||||
|
htmlContent = await addSyntaxHighlighting(htmlContent)
|
||||||
|
|
||||||
|
const filename = context.fileName ? `${context.fileName}.pdf` : 'note.pdf'
|
||||||
|
const result = await window.api.export.toPDF(htmlContent, filename)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
logger.info('PDF exported successfully to:', result.filePath)
|
||||||
|
window.toast.success(i18n.t('message.success.pdf.export'))
|
||||||
|
} else {
|
||||||
|
logger.error('PDF export failed:', result.message)
|
||||||
|
if (!result.message.includes('canceled')) {
|
||||||
|
window.toast.error(i18n.t('message.error.pdf.export', { message: result.message }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('PDF export error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user