Compare commits

...

10 Commits

15 changed files with 411 additions and 7 deletions

View File

@@ -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

View File

@@ -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',

View File

@@ -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>
`

View File

@@ -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)

View File

@@ -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 }
}
}
} }

View File

@@ -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),

View File

@@ -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",

View File

@@ -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": "成功导出到知识库",

View File

@@ -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": "成功匯出至知識庫",

View File

@@ -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": {

View File

@@ -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)
} }

View File

@@ -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',

View File

@@ -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}

View File

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

View 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)
}
}