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
|
||||
directories:
|
||||
buildResources: build
|
||||
extraResources:
|
||||
- from: 'src/renderer/src/assets/styles'
|
||||
to: 'app.asar.unpacked/resources/styles'
|
||||
filter:
|
||||
- 'font.css'
|
||||
- 'color.css'
|
||||
- 'richtext.css'
|
||||
|
||||
protocols:
|
||||
- name: Cherry Studio
|
||||
|
||||
@@ -191,6 +191,7 @@ export enum IpcChannel {
|
||||
FileService_Retrieve = 'file-service:retrieve',
|
||||
|
||||
Export_Word = 'export:word',
|
||||
Export_PDF = 'export:pdf',
|
||||
|
||||
Shortcuts_Update = 'shortcuts:update',
|
||||
|
||||
|
||||
@@ -3,3 +3,32 @@ export const isWin = process.platform === 'win32'
|
||||
export const isLinux = process.platform === 'linux'
|
||||
export const isDev = process.env.NODE_ENV === 'development'
|
||||
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
|
||||
ipcMain.handle(IpcChannel.Export_Word, exportService.exportToWord.bind(exportService))
|
||||
|
||||
// PDF export
|
||||
ipcMain.handle(IpcChannel.Export_PDF, exportService.exportToPDF.bind(exportService))
|
||||
|
||||
// open path
|
||||
ipcMain.handle(IpcChannel.Open_Path, async (_, path: string) => {
|
||||
await shell.openPath(path)
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/* oxlint-disable no-case-declarations */
|
||||
// ExportService
|
||||
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { PRINT_HTML_TEMPLATE } from '@main/constant'
|
||||
import {
|
||||
AlignmentType,
|
||||
BorderStyle,
|
||||
@@ -18,10 +23,11 @@ import {
|
||||
VerticalAlign,
|
||||
WidthType
|
||||
} from 'docx'
|
||||
import { dialog } from 'electron'
|
||||
import { app, BrowserWindow, dialog } from 'electron'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
import { fileStorage } from './FileStorage'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
const logger = loggerService.withContext('ExportService')
|
||||
export class ExportService {
|
||||
@@ -405,4 +411,116 @@ export class ExportService {
|
||||
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)
|
||||
},
|
||||
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: {
|
||||
getVaults: () => ipcRenderer.invoke(IpcChannel.Obsidian_GetVaults),
|
||||
|
||||
@@ -1385,6 +1385,9 @@
|
||||
"no_api_key": "Notion ApiKey or Notion DatabaseID is not configured",
|
||||
"no_content": "There is nothing to export to Notion."
|
||||
},
|
||||
"pdf": {
|
||||
"export": "Failed to export to PDF"
|
||||
},
|
||||
"siyuan": {
|
||||
"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"
|
||||
@@ -1487,6 +1490,9 @@
|
||||
"notion": {
|
||||
"export": "Successfully exported to Notion"
|
||||
},
|
||||
"pdf": {
|
||||
"export": "Successfully exported to PDF"
|
||||
},
|
||||
"siyuan": {
|
||||
"export": "Successfully exported to Siyuan Note"
|
||||
},
|
||||
@@ -1707,6 +1713,7 @@
|
||||
"drop_markdown_hint": "Drop .md files or folders here to import",
|
||||
"empty": "No notes available yet",
|
||||
"expand": "unfold",
|
||||
"exportPDF": "Export to PDF",
|
||||
"export_failed": "Failed to export to knowledge base",
|
||||
"export_knowledge": "Export notes to knowledge base",
|
||||
"export_success": "Successfully exported to the knowledge base",
|
||||
|
||||
@@ -1385,6 +1385,9 @@
|
||||
"no_api_key": "未配置 Notion API Key 或 Notion Database ID",
|
||||
"no_content": "无可导出到 Notion 的内容"
|
||||
},
|
||||
"pdf": {
|
||||
"export": "导出 PDF 失败"
|
||||
},
|
||||
"siyuan": {
|
||||
"export": "导出思源笔记失败,请检查连接状态并对照文档检查配置",
|
||||
"no_config": "未配置思源笔记 API 地址或令牌"
|
||||
@@ -1487,6 +1490,9 @@
|
||||
"notion": {
|
||||
"export": "成功导出到 Notion"
|
||||
},
|
||||
"pdf": {
|
||||
"export": "成功导出为 PDF"
|
||||
},
|
||||
"siyuan": {
|
||||
"export": "导出到思源笔记成功"
|
||||
},
|
||||
@@ -1707,6 +1713,7 @@
|
||||
"drop_markdown_hint": "拖拽 .md 文件或目录到此处导入",
|
||||
"empty": "暂无笔记",
|
||||
"expand": "展开",
|
||||
"exportPDF": "导出为PDF",
|
||||
"export_failed": "导出到知识库失败",
|
||||
"export_knowledge": "导出笔记到知识库",
|
||||
"export_success": "成功导出到知识库",
|
||||
|
||||
@@ -1385,6 +1385,9 @@
|
||||
"no_api_key": "未設定 Notion API Key 或 Notion Database ID",
|
||||
"no_content": "沒有可匯出至 Notion 的內容"
|
||||
},
|
||||
"pdf": {
|
||||
"export": "導出 PDF 失敗"
|
||||
},
|
||||
"siyuan": {
|
||||
"export": "導出思源筆記失敗,請檢查連接狀態並對照文檔檢查配置",
|
||||
"no_config": "未配置思源筆記 API 地址或令牌"
|
||||
@@ -1487,6 +1490,9 @@
|
||||
"notion": {
|
||||
"export": "成功匯出到 Notion"
|
||||
},
|
||||
"pdf": {
|
||||
"export": "成功導出為 PDF"
|
||||
},
|
||||
"siyuan": {
|
||||
"export": "導出到思源筆記成功"
|
||||
},
|
||||
@@ -1707,6 +1713,7 @@
|
||||
"drop_markdown_hint": "拖拽 .md 文件或資料夾到此處導入",
|
||||
"empty": "暫無筆記",
|
||||
"expand": "展開",
|
||||
"exportPDF": "匯出為PDF",
|
||||
"export_failed": "匯出至知識庫失敗",
|
||||
"export_knowledge": "匯出筆記至知識庫",
|
||||
"export_success": "成功匯出至知識庫",
|
||||
|
||||
@@ -540,7 +540,7 @@
|
||||
"title": "コード実行"
|
||||
},
|
||||
"code_fancy_block": {
|
||||
"label": "<translate_input>\n装飾的なコードブロック\n</translate_input>",
|
||||
"label": "装飾的なコードブロック",
|
||||
"tip": "より見栄えの良いコードブロックスタイルを使用する、例えばHTMLカード"
|
||||
},
|
||||
"code_image_tools": {
|
||||
|
||||
@@ -13,10 +13,19 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { menuItems } from './MenuConfig'
|
||||
import { ExportContext, handleExportPDF } from './utils/exportUtils'
|
||||
|
||||
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 { activeNode } = useActiveNode(notesTree)
|
||||
const [breadcrumbItems, setBreadcrumbItems] = useState<
|
||||
@@ -90,6 +99,15 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpand
|
||||
[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) => {
|
||||
if (item.type === 'divider') {
|
||||
return { type: 'divider' as const, key: item.key }
|
||||
@@ -131,6 +149,8 @@ const HeaderNavbar = ({ notesTree, getCurrentNoteContent, onToggleStar, onExpand
|
||||
onClick: () => {
|
||||
if (item.copyAction) {
|
||||
handleCopyContent()
|
||||
} else if (item.exportPdfAction) {
|
||||
handleExportPDFAction()
|
||||
} else if (item.action) {
|
||||
item.action(settings, updateSettings)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
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 { ExportContext } from './utils/exportUtils'
|
||||
|
||||
export interface MenuItem {
|
||||
key: string
|
||||
type?: 'divider' | 'component'
|
||||
labelKey: string
|
||||
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[]
|
||||
isActive?: (settings: NotesSettings) => boolean
|
||||
component?: (settings: NotesSettings, updateSettings: (newSettings: Partial<NotesSettings>) => void) => ReactNode
|
||||
copyAction?: boolean
|
||||
exportPdfAction?: boolean
|
||||
}
|
||||
|
||||
export const menuItems: MenuItem[] = [
|
||||
@@ -21,6 +28,12 @@ export const menuItems: MenuItem[] = [
|
||||
icon: Copy,
|
||||
copyAction: true
|
||||
},
|
||||
{
|
||||
key: 'export-pdf',
|
||||
labelKey: 'notes.exportPDF',
|
||||
icon: Download,
|
||||
exportPdfAction: true
|
||||
},
|
||||
{
|
||||
key: 'divider0',
|
||||
type: 'divider',
|
||||
|
||||
@@ -46,6 +46,7 @@ import styled from 'styled-components'
|
||||
import HeaderNavbar from './HeaderNavbar'
|
||||
import NotesEditor from './NotesEditor'
|
||||
import NotesSidebar from './NotesSidebar'
|
||||
import { handleExportPDF } from './utils/exportUtils'
|
||||
|
||||
const logger = loggerService.withContext('NotesPage')
|
||||
|
||||
@@ -751,6 +752,50 @@ const NotesPage: FC = () => {
|
||||
}
|
||||
}, [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 (
|
||||
<Container id="notes-page">
|
||||
<Navbar>
|
||||
@@ -778,6 +823,7 @@ const NotesPage: FC = () => {
|
||||
onMoveNode={handleMoveNode}
|
||||
onSortNodes={handleSortNodes}
|
||||
onUploadFiles={handleUploadFiles}
|
||||
onExportToPDF={handleExportToPDF}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -785,6 +831,8 @@ const NotesPage: FC = () => {
|
||||
<EditorWrapper>
|
||||
<HeaderNavbar
|
||||
notesTree={notesTree}
|
||||
editorRef={editorRef}
|
||||
currentContent={currentContent}
|
||||
getCurrentNoteContent={getCurrentNoteContent}
|
||||
onToggleStar={handleToggleStar}
|
||||
onExpandPath={handleExpandPath}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Dropdown, Input, InputRef, MenuProps } from 'antd'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Edit3,
|
||||
File,
|
||||
FilePlus,
|
||||
@@ -38,6 +39,7 @@ interface NotesSidebarProps {
|
||||
onMoveNode: (sourceNodeId: string, targetNodeId: string, position: 'before' | 'after' | 'inside') => void
|
||||
onSortNodes: (sortType: NotesSortType) => void
|
||||
onUploadFiles: (files: File[]) => void
|
||||
onExportToPDF?: (nodeId: string) => void
|
||||
notesTree: NotesTreeNode[]
|
||||
selectedFolderId?: string | null
|
||||
}
|
||||
@@ -206,6 +208,7 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
onMoveNode,
|
||||
onSortNodes,
|
||||
onUploadFiles,
|
||||
onExportToPDF,
|
||||
notesTree,
|
||||
selectedFolderId
|
||||
}) => {
|
||||
@@ -336,6 +339,15 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
[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) => {
|
||||
setDraggedNodeId(node.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
@@ -525,6 +537,14 @@ const NotesSidebar: FC<NotesSidebarProps> = ({
|
||||
onClick: () => {
|
||||
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
|
||||
},
|
||||
[t, handleStartEdit, onToggleStar, handleExportKnowledge, handleDeleteNode]
|
||||
[t, handleStartEdit, onToggleStar, handleExportKnowledge, handleExportToPDF, handleDeleteNode]
|
||||
)
|
||||
|
||||
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