增加图片导出功能
This commit is contained in:
Generated
+50
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,4 +46,101 @@ export function loadPreviewLineHeight(): string {
|
|||||||
const n = Number.parseFloat(raw)
|
const n = Number.parseFloat(raw)
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user