增加图片导出功能

This commit is contained in:
2026-06-06 20:25:45 +08:00
parent d744d6c049
commit 5f54a5ab6f
5 changed files with 509 additions and 2 deletions
+50
View File
@@ -15,6 +15,7 @@
"@codemirror/view": "^6.36.4", "@codemirror/view": "^6.36.4",
"dompurify": "^3.2.5", "dompurify": "^3.2.5",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"html2canvas": "^1.4.1",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"markdown-it-multimd-table": "^4.2.3", "markdown-it-multimd-table": "^4.2.3",
"mermaid": "^11.15.0", "mermaid": "^11.15.0",
@@ -2878,6 +2879,15 @@
"node": "18 || 20 || >=22" "node": "18 || 20 || >=22"
} }
}, },
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
@@ -3406,6 +3416,15 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
@@ -5171,6 +5190,19 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-cache-semantics": { "node_modules/http-cache-semantics": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmmirror.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", "resolved": "https://registry.npmmirror.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@@ -6802,6 +6834,15 @@
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
}, },
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/tiny-async-pool": { "node_modules/tiny-async-pool": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmmirror.com/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", "resolved": "https://registry.npmmirror.com/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz",
@@ -6996,6 +7037,15 @@
"dev": true, "dev": true,
"license": "(WTFPL OR MIT)" "license": "(WTFPL OR MIT)"
}, },
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/uuid": { "node_modules/uuid": {
"version": "14.0.0", "version": "14.0.0",
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-14.0.0.tgz", "resolved": "https://registry.npmmirror.com/uuid/-/uuid-14.0.0.tgz",
+1
View File
@@ -26,6 +26,7 @@
"@codemirror/view": "^6.36.4", "@codemirror/view": "^6.36.4",
"dompurify": "^3.2.5", "dompurify": "^3.2.5",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"html2canvas": "^1.4.1",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"markdown-it-multimd-table": "^4.2.3", "markdown-it-multimd-table": "^4.2.3",
"mermaid": "^11.15.0", "mermaid": "^11.15.0",
+110 -1
View File
@@ -17,7 +17,7 @@ import {
import { BACKGROUNDS, FONTS, validBackgroundId, validFontId, type ViewMode } from './appearance' import { BACKGROUNDS, FONTS, validBackgroundId, validFontId, type ViewMode } from './appearance'
import type { Tab } from './types' import type { Tab } from './types'
import { AppModals } from './AppModals' import { AppModals } from './AppModals'
import { clamp, newTab, tabLabel, isDirty, loadViewMode, viewModeLabel, loadPreviewLineHeight, normalizePathKey } from './utils' import { clamp, newTab, tabLabel, isDirty, loadViewMode, viewModeLabel, loadPreviewLineHeight, normalizePathKey, exportElementToImageData } from './utils'
import { import {
BG_KEY, FONT_KEY, VIEW_KEY, BROWSE_KEY, SIDEBAR_W_KEY, SIDEBAR_HIDDEN_KEY, BG_KEY, FONT_KEY, VIEW_KEY, BROWSE_KEY, SIDEBAR_W_KEY, SIDEBAR_HIDDEN_KEY,
STATS_W_KEY, STATS_HIDDEN_KEY, SPLIT_KEY, PREVIEW_W_KEY, PREVIEW_LINE_KEY, SESSION_KEY, AUTO_SAVE_KEY STATS_W_KEY, STATS_HIDDEN_KEY, SPLIT_KEY, PREVIEW_W_KEY, PREVIEW_LINE_KEY, SESSION_KEY, AUTO_SAVE_KEY
@@ -127,6 +127,13 @@ export default function App(): React.ReactElement {
const [searchVisible, setSearchVisible] = React.useState(false) const [searchVisible, setSearchVisible] = React.useState(false)
const [searchQuery, setSearchQuery] = React.useState('') const [searchQuery, setSearchQuery] = React.useState('')
const [dragOver, setDragOver] = React.useState(false) const [dragOver, setDragOver] = React.useState(false)
const [imagePreviewData, setImagePreviewData] = React.useState<string | null>(null)
const [imagePreviewFileName, setImagePreviewFileName] = React.useState('')
const [imageScale, setImageScale] = React.useState(1)
const [imagePosition, setImagePosition] = React.useState({ x: 0, y: 0 })
const [isDragging, setIsDragging] = React.useState(false)
const [dragStart, setDragStart] = React.useState({ x: 0, y: 0 })
const [isExportingImage, setIsExportingImage] = React.useState(false)
const activeTab = tabs.find((t) => t.id === activeId) ?? null const activeTab = tabs.find((t) => t.id === activeId) ?? null
const activeDoc = activeTab?.content ?? '' const activeDoc = activeTab?.content ?? ''
@@ -997,6 +1004,60 @@ ${previewHtml}
window.setTimeout(() => setStatusText(''), 2000) window.setTimeout(() => setStatusText(''), 2000)
}, [activeDoc, activeTab, previewHtml]) }, [activeDoc, activeTab, previewHtml])
const handleExportImage = React.useCallback(async (): Promise<void> => {
const articleEl =
viewMode === 'read'
? readPreviewArticleRef.current
: viewMode === 'live'
? livePreviewArticleRef.current
: hybridPreviewArticleRef.current
if (!articleEl) {
console.error('Article element not found for viewMode:', viewMode)
setStatusText('无法获取文档内容')
window.setTimeout(() => setStatusText(''), 2000)
return
}
const fileName = `${activeTab?.filePath ? tabLabel(activeTab).replace(/\.\w+$/, '') : 'document'}.png`
try {
setIsExportingImage(true)
const imageData = await exportElementToImageData(articleEl)
setImagePreviewData(imageData)
setImagePreviewFileName(fileName)
setIsExportingImage(false)
} catch (error) {
console.error('Error exporting image:', error)
setIsExportingImage(false)
const errorMessage = error instanceof Error ? error.message : '未知错误'
setStatusText(`导出图片失败: ${errorMessage}`)
window.setTimeout(() => setStatusText(''), 3000)
}
}, [activeTab, viewMode])
const handleCloseImagePreview = React.useCallback((): void => {
setImagePreviewData(null)
setImageScale(1)
setImagePosition({ x: 0, y: 0 })
setIsExportingImage(false)
setIsDragging(false)
}, [])
const handleResetZoom = React.useCallback((): void => {
setImageScale(1)
setImagePosition({ x: 0, y: 0 })
}, [])
const handleDownloadImage = React.useCallback((): void => {
if (!imagePreviewData) return
const a = document.createElement('a')
a.href = imagePreviewData
a.download = imagePreviewFileName
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setStatusText('图片下载成功')
window.setTimeout(() => setStatusText(''), 3000)
}, [imagePreviewData, imagePreviewFileName])
const deleteFileAt = React.useCallback( const deleteFileAt = React.useCallback(
async (filePath: string): Promise<void> => { async (filePath: string): Promise<void> => {
if (!api) return if (!api) return
@@ -1349,6 +1410,14 @@ ${previewHtml}
> >
HTML HTML
</button> </button>
<button
type="button"
onClick={() => void handleExportImage()}
disabled={!activeDoc.trim()}
title="导出当前文档为图片"
>
📷
</button>
<button <button
type="button" type="button"
onClick={() => setAutoSave((v) => !v)} onClick={() => setAutoSave((v) => !v)}
@@ -1509,6 +1578,46 @@ ${previewHtml}
</div> </div>
</div> </div>
) : null} ) : null}
{isExportingImage && (
<div className="image-export-loading">
<div className="image-export-loading-spinner"></div>
<div className="image-export-loading-text">...</div>
<div className="image-export-loading-hint"></div>
</div>
)}
{imagePreviewData && (
<div className="image-preview-overlay" onClick={handleCloseImagePreview}>
<div className="image-preview-modal" onClick={(e) => e.stopPropagation()}>
<div className="image-preview-header">
<h3 className="image-preview-title">📷 </h3>
<button
className="image-preview-close"
onClick={handleCloseImagePreview}
title="关闭"
>
</button>
</div>
<div className="image-preview-content">
<img
src={imagePreviewData}
alt="预览"
className="image-preview-img"
/>
</div>
<div className="image-preview-footer">
<button
className="image-preview-download"
onClick={handleDownloadImage}
>
</button>
</div>
</div>
</div>
)}
</div> </div>
) )
} }
+250
View File
@@ -2654,3 +2654,253 @@ html[data-bg='22'] .app-root {
outline: none; outline: none;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent);
} }
/* 图片导出加载遮罩 */
.image-export-loading {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1001;
animation: loadingFadeIn 0.3s ease-out;
}
@keyframes loadingFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.image-export-loading-spinner {
width: 60px;
height: 60px;
border: 4px solid rgba(255, 255, 255, 0.1);
border-top-color: #0ea5e9;
border-radius: 50%;
animation: spin 1s cubic-bezier(0.4, 0, 0.2, 1) infinite;
margin-bottom: 24px;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.image-export-loading-text {
font-size: 20px;
font-weight: 600;
color: #ffffff;
margin-bottom: 8px;
animation: loadingPulse 2s ease-in-out infinite;
}
@keyframes loadingPulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.image-export-loading-hint {
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
}
/* 图片预览弹窗 */
.image-preview-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 24px;
animation: overlayFadeIn 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes overlayFadeIn {
from {
opacity: 0;
backdrop-filter: blur(0px);
}
to {
opacity: 1;
backdrop-filter: blur(4px);
}
}
.image-preview-modal {
background: #f5f5f5;
border-radius: 16px;
max-width: 90vw;
max-height: 90vh;
width: 100%;
display: flex;
flex-direction: column;
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.4);
animation: modalSlideUp 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes modalSlideUp {
from {
opacity: 0;
transform: translateY(40px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.image-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #e5e7eb;
}
.image-preview-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #111827;
}
.image-preview-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.image-preview-zoom-out,
.image-preview-zoom-in,
.image-preview-reset,
.image-preview-close {
width: 32px;
height: 32px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
color: #111827;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.image-preview-zoom-out:hover,
.image-preview-zoom-in:hover,
.image-preview-reset:hover {
border-color: #0ea5e9;
background: rgba(14, 165, 233, 0.1);
}
.image-preview-close:hover {
border-color: #ef4444;
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.image-preview-scale {
font-size: 13px;
color: #6b7280;
min-width: 50px;
text-align: center;
}
.image-preview-content {
flex: 1;
overflow: auto;
padding: 20px;
display: flex;
align-items: center;
justify-content: center;
background: #f3f4f6;
}
.image-preview-img {
max-width: 100%;
max-height: 60vh;
width: auto;
height: auto;
object-fit: contain;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
animation: imageFadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1) 0.2s both;
}
@keyframes imageFadeIn {
from {
opacity: 0;
transform: scale(0.98);
}
to {
opacity: 1;
transform: scale(1);
}
}
.image-preview-footer {
padding: 16px 20px;
border-top: 1px solid #e5e7eb;
display: flex;
justify-content: center;
}
.image-preview-download {
padding: 12px 32px;
border: none;
border-radius: 10px;
background: #0ea5e9;
color: #fff;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.image-preview-download:hover {
background: #0284c7;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(14, 165, 233, 0.3);
}
/* 搜索高亮动画 */
.search-highlight {
background-color: rgba(250, 204, 21, 0.5);
border-radius: 2px;
animation: searchPulse 1s ease-in-out;
}
@keyframes searchPulse {
0%, 100% {
background-color: rgba(250, 204, 21, 0.5);
}
50% {
background-color: rgba(250, 204, 21, 0.8);
}
}
+97
View File
@@ -47,3 +47,100 @@ export function loadPreviewLineHeight(): string {
if (!Number.isFinite(n)) return '1.5' if (!Number.isFinite(n)) return '1.5'
return String(Math.min(3, Math.max(1, n))) return String(Math.min(3, Math.max(1, n)))
} }
export async function exportElementToImageData(element: HTMLElement): Promise<string> {
const html2canvas = (await import('html2canvas')).default
// 创建一个完全独立的 iframe 来渲染内容
const iframe = document.createElement('iframe')
iframe.style.cssText = 'position:absolute;left:-9999px;top:-9999px;width:800px;height:600px;border:none;'
document.body.appendChild(iframe)
const iframeDoc = iframe.contentDocument!
const iframeWin = iframe.contentWindow!
// 复制必要的样式
const computedStyle = iframeWin.getComputedStyle(element)
const bgColor = computedStyle.backgroundColor || '#ffffff'
const color = computedStyle.color || '#333333'
const fontFamily = computedStyle.fontFamily || 'sans-serif'
const fontSize = computedStyle.fontSize || '16px'
const lineHeight = computedStyle.lineHeight || '1.5'
// 写入干净的 HTML 和 CSS
iframeDoc.open()
iframeDoc.write(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: ${fontFamily};
font-size: ${fontSize};
line-height: ${lineHeight};
color: ${color};
background: ${bgColor};
padding: 20px;
}
h1 { font-size: 2em; margin: 0.5em 0; }
h2 { font-size: 1.5em; margin: 0.5em 0; }
h3 { font-size: 1.25em; margin: 0.5em 0; }
p { margin: 0.5em 0; }
pre, code {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9em;
background: #f5f5f5;
padding: 0.2em 0.4em;
border-radius: 3px;
}
pre {
padding: 1em;
overflow-x: auto;
}
blockquote {
border-left: 3px solid #ddd;
padding-left: 1em;
margin: 1em 0;
color: #666;
}
img { max-width: 100%; height: auto; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; }
a { color: #0066cc; }
hr { border: none; border-top: 1px solid #ddd; margin: 1em 0; }
</style>
</head>
<body>${element.innerHTML}</body>
</html>
`)
iframeDoc.close()
// 等待 iframe 加载
await new Promise<void>(resolve => {
iframeDoc.body ? resolve() : iframe.addEventListener('load', () => resolve(), { once: true })
})
// 等待任何异步内容(如图片)加载
await new Promise<void>(resolve => setTimeout(resolve, 500))
try {
const canvas = await html2canvas(iframeDoc.body, {
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff',
scale: 2,
logging: false,
width: iframeDoc.body.scrollWidth + 40,
height: iframeDoc.body.scrollHeight + 40
})
return canvas.toDataURL('image/png')
} finally {
// 清理
if (iframe.parentNode) {
iframe.parentNode.removeChild(iframe)
}
}
}