diff --git a/package-lock.json b/package-lock.json index aeb802a..e5e9f27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@codemirror/view": "^6.36.4", "dompurify": "^3.2.5", "highlight.js": "^11.11.1", + "html2canvas": "^1.4.1", "markdown-it": "^14.1.0", "markdown-it-multimd-table": "^4.2.3", "mermaid": "^11.15.0", @@ -2878,6 +2879,15 @@ "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": { "version": "1.5.1", "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", @@ -3406,6 +3416,15 @@ "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": { "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", @@ -5171,6 +5190,19 @@ "dev": true, "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": { "version": "4.2.0", "resolved": "https://registry.npmmirror.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -6802,6 +6834,15 @@ "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": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", @@ -6996,6 +7037,15 @@ "dev": true, "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": { "version": "14.0.0", "resolved": "https://registry.npmmirror.com/uuid/-/uuid-14.0.0.tgz", diff --git a/package.json b/package.json index 1b340bd..3f66734 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@codemirror/view": "^6.36.4", "dompurify": "^3.2.5", "highlight.js": "^11.11.1", + "html2canvas": "^1.4.1", "markdown-it": "^14.1.0", "markdown-it-multimd-table": "^4.2.3", "mermaid": "^11.15.0", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b9f64c7..fd2d387 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -17,7 +17,7 @@ import { import { BACKGROUNDS, FONTS, validBackgroundId, validFontId, type ViewMode } from './appearance' import type { Tab } from './types' 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 { 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 @@ -127,6 +127,13 @@ export default function App(): React.ReactElement { const [searchVisible, setSearchVisible] = React.useState(false) const [searchQuery, setSearchQuery] = React.useState('') const [dragOver, setDragOver] = React.useState(false) + const [imagePreviewData, setImagePreviewData] = React.useState(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 activeDoc = activeTab?.content ?? '' @@ -997,6 +1004,60 @@ ${previewHtml} window.setTimeout(() => setStatusText(''), 2000) }, [activeDoc, activeTab, previewHtml]) + const handleExportImage = React.useCallback(async (): Promise => { + 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( async (filePath: string): Promise => { if (!api) return @@ -1349,6 +1410,14 @@ ${previewHtml} > 导出 HTML + + +
+ 预览 +
+
+ +
+ + + )} ) } diff --git a/src/renderer/src/index.css b/src/renderer/src/index.css index d578ef7..1c17f99 100644 --- a/src/renderer/src/index.css +++ b/src/renderer/src/index.css @@ -2654,3 +2654,253 @@ html[data-bg='22'] .app-root { outline: none; 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); + } +} diff --git a/src/renderer/src/utils.ts b/src/renderer/src/utils.ts index 556eb40..3e4f12a 100644 --- a/src/renderer/src/utils.ts +++ b/src/renderer/src/utils.ts @@ -46,4 +46,101 @@ export function loadPreviewLineHeight(): string { const n = Number.parseFloat(raw) if (!Number.isFinite(n)) return '1.5' return String(Math.min(3, Math.max(1, n))) -} \ No newline at end of file +} + +export async function exportElementToImageData(element: HTMLElement): Promise { + 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(` + + + + + + + ${element.innerHTML} + + `) + iframeDoc.close() + + // 等待 iframe 加载 + await new Promise(resolve => { + iframeDoc.body ? resolve() : iframe.addEventListener('load', () => resolve(), { once: true }) + }) + + // 等待任何异步内容(如图片)加载 + await new Promise(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) + } + } +}