mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 10:40:07 +08:00
feat: capture iframe as image (#9607)
* refactor: update capture function signatures * feat: capture html as png * refactor: rename the ipc channel * fix: stop propagate double clicks * fix: improve conversion from title to filename * refactor: improve capture, add more capture options * fix: button icons * refactor: add success message
This commit is contained in:
parent
46e731dee0
commit
649a2a645c
@ -2,7 +2,7 @@ import { CodeOutlined } from '@ant-design/icons'
|
||||
import { loggerService } from '@logger'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
import { extractTitle } from '@renderer/utils/formats'
|
||||
import { extractHtmlTitle, getFileNameFromHtmlTitle } from '@renderer/utils/formats'
|
||||
import { Button } from 'antd'
|
||||
import { Code, DownloadIcon, Globe, LinkIcon, Sparkles } from 'lucide-react'
|
||||
import { FC, useState } from 'react'
|
||||
@ -28,7 +28,7 @@ const getTerminalStyles = (theme: ThemeMode) => ({
|
||||
|
||||
const HtmlArtifactsCard: FC<Props> = ({ html, onSave, isStreaming = false }) => {
|
||||
const { t } = useTranslation()
|
||||
const title = extractTitle(html) || 'HTML Artifacts'
|
||||
const title = extractHtmlTitle(html) || 'HTML Artifacts'
|
||||
const [isPopupOpen, setIsPopupOpen] = useState(false)
|
||||
const { theme } = useTheme()
|
||||
|
||||
@ -48,7 +48,7 @@ const HtmlArtifactsCard: FC<Props> = ({ html, onSave, isStreaming = false }) =>
|
||||
}
|
||||
|
||||
const handleDownload = async () => {
|
||||
const fileName = `${title.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-') || 'html-artifact'}.html`
|
||||
const fileName = `${getFileNameFromHtmlTitle(title) || 'html-artifact'}.html`
|
||||
await window.api.file.save(fileName, htmlContent)
|
||||
window.message.success({ content: t('message.download.success'), key: 'download' })
|
||||
}
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor'
|
||||
import { CopyIcon, FilePngIcon } from '@renderer/components/Icons'
|
||||
import { isLinux, isMac, isWin } from '@renderer/config/constant'
|
||||
import { useTemporaryValue } from '@renderer/hooks/useTemporaryValue'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Button, Modal, Splitter, Tooltip, Typography } from 'antd'
|
||||
import { Code, Eye, Maximize2, Minimize2, SaveIcon, SquareSplitHorizontal, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { extractHtmlTitle, getFileNameFromHtmlTitle } from '@renderer/utils/formats'
|
||||
import { captureScrollableIframeAsBlob, captureScrollableIframeAsDataURL } from '@renderer/utils/image'
|
||||
import { Button, Dropdown, Modal, Splitter, Tooltip, Typography } from 'antd'
|
||||
import { Camera, Check, Code, Eye, Maximize2, Minimize2, SaveIcon, SquareSplitHorizontal, X } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -21,7 +25,9 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
const { t } = useTranslation()
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('split')
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [saved, setSaved] = useTemporaryValue(false, 2000)
|
||||
const codeEditorRef = useRef<CodeEditorHandles>(null)
|
||||
const previewFrameRef = useRef<HTMLIFrameElement>(null)
|
||||
|
||||
// Prevent body scroll when fullscreen
|
||||
useEffect(() => {
|
||||
@ -38,8 +44,32 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
|
||||
const handleSave = () => {
|
||||
codeEditorRef.current?.save?.()
|
||||
setSaved(true)
|
||||
}
|
||||
|
||||
const handleCapture = useCallback(
|
||||
async (to: 'file' | 'clipboard') => {
|
||||
const title = extractHtmlTitle(html)
|
||||
const fileName = getFileNameFromHtmlTitle(title) || 'html-artifact'
|
||||
|
||||
if (to === 'file') {
|
||||
const dataUrl = await captureScrollableIframeAsDataURL(previewFrameRef)
|
||||
if (dataUrl) {
|
||||
window.api.file.saveImage(fileName, dataUrl)
|
||||
}
|
||||
}
|
||||
if (to === 'clipboard') {
|
||||
await captureScrollableIframeAsBlob(previewFrameRef, async (blob) => {
|
||||
if (blob) {
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
||||
window.message.success(t('message.copy.success'))
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
[html, t]
|
||||
)
|
||||
|
||||
const renderHeader = () => (
|
||||
<ModalHeader onDoubleClick={() => setIsFullscreen(!isFullscreen)} className={classNames({ drag: isFullscreen })}>
|
||||
<HeaderLeft $isFullscreen={isFullscreen}>
|
||||
@ -47,7 +77,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
</HeaderLeft>
|
||||
|
||||
<HeaderCenter>
|
||||
<ViewControls>
|
||||
<ViewControls onDoubleClick={(e) => e.stopPropagation()}>
|
||||
<ViewButton
|
||||
size="small"
|
||||
type={viewMode === 'split' ? 'primary' : 'default'}
|
||||
@ -72,7 +102,29 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
</ViewControls>
|
||||
</HeaderCenter>
|
||||
|
||||
<HeaderRight $isFullscreen={isFullscreen}>
|
||||
<HeaderRight $isFullscreen={isFullscreen} onDoubleClick={(e) => e.stopPropagation()}>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
label: t('html_artifacts.capture.to_file'),
|
||||
key: 'capture_to_file',
|
||||
icon: <FilePngIcon size={14} className="lucide-custom" />,
|
||||
onClick: () => handleCapture('file')
|
||||
},
|
||||
{
|
||||
label: t('html_artifacts.capture.to_clipboard'),
|
||||
key: 'capture_to_clipboard',
|
||||
icon: <CopyIcon size={14} className="lucide-custom" />,
|
||||
onClick: () => handleCapture('clipboard')
|
||||
}
|
||||
]
|
||||
}}>
|
||||
<Tooltip title={t('html_artifacts.capture.label')} mouseLeaveDelay={0}>
|
||||
<Button type="text" icon={<Camera size={16} />} className="nodrag" />
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
<Button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
type="text"
|
||||
@ -104,10 +156,16 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
/>
|
||||
<ToolbarWrapper>
|
||||
<Tooltip title={t('code_block.edit.save.label')} mouseLeaveDelay={0}>
|
||||
<Button
|
||||
<ToolbarButton
|
||||
shape="circle"
|
||||
size="large"
|
||||
icon={<SaveIcon size={16} className="custom-lucide" />}
|
||||
icon={
|
||||
saved ? (
|
||||
<Check size={16} color="var(--color-status-success)" />
|
||||
) : (
|
||||
<SaveIcon size={16} className="custom-lucide" />
|
||||
)
|
||||
}
|
||||
onClick={handleSave}
|
||||
/>
|
||||
</Tooltip>
|
||||
@ -119,6 +177,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
|
||||
<PreviewSection>
|
||||
{html.trim() ? (
|
||||
<PreviewFrame
|
||||
ref={previewFrameRef}
|
||||
key={html} // Force recreate iframe when preview content changes
|
||||
srcDoc={html}
|
||||
title="HTML Preview"
|
||||
@ -373,4 +432,12 @@ const ToolbarWrapper = styled.div`
|
||||
z-index: 1;
|
||||
`
|
||||
|
||||
const ToolbarButton = styled(Button)`
|
||||
border: none;
|
||||
box-shadow:
|
||||
0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
`
|
||||
|
||||
export default HtmlArtifactsPopup
|
||||
|
||||
@ -19,7 +19,7 @@ import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { pyodideService } from '@renderer/services/PyodideService'
|
||||
import { getExtensionByLanguage } from '@renderer/utils/code-language'
|
||||
import { extractTitle } from '@renderer/utils/formats'
|
||||
import { extractHtmlTitle } from '@renderer/utils/formats'
|
||||
import dayjs from 'dayjs'
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -136,7 +136,7 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
|
||||
|
||||
// 尝试提取 HTML 标题
|
||||
if (language === 'html' && children.includes('</html>')) {
|
||||
fileName = extractTitle(children) || ''
|
||||
fileName = extractHtmlTitle(children) || ''
|
||||
}
|
||||
|
||||
// 默认使用日期格式命名
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { CSSProperties, SVGProps } from 'react'
|
||||
|
||||
interface BaseFileIconProps extends SVGProps<SVGSVGElement> {
|
||||
size?: string
|
||||
size?: string | number
|
||||
text?: string
|
||||
}
|
||||
|
||||
|
||||
@ -916,6 +916,11 @@
|
||||
"title": "Topics Search"
|
||||
},
|
||||
"html_artifacts": {
|
||||
"capture": {
|
||||
"label": "Capture Page",
|
||||
"to_clipboard": "Copy to Clipboard",
|
||||
"to_file": "Save as Image"
|
||||
},
|
||||
"code": "Code",
|
||||
"empty_preview": "No content to display",
|
||||
"generating": "Generating",
|
||||
|
||||
@ -916,6 +916,11 @@
|
||||
"title": "トピック検索"
|
||||
},
|
||||
"html_artifacts": {
|
||||
"capture": {
|
||||
"label": "ページをキャプチャ",
|
||||
"to_clipboard": "クリップボードにコピー",
|
||||
"to_file": "画像として保存"
|
||||
},
|
||||
"code": "コード",
|
||||
"empty_preview": "表示するコンテンツがありません",
|
||||
"generating": "生成中",
|
||||
|
||||
@ -916,6 +916,11 @@
|
||||
"title": "Поиск топиков"
|
||||
},
|
||||
"html_artifacts": {
|
||||
"capture": {
|
||||
"label": "Захват страницы",
|
||||
"to_clipboard": "Копировать в буфер обмена",
|
||||
"to_file": "Сохранить как изображение"
|
||||
},
|
||||
"code": "Код",
|
||||
"empty_preview": "Нет содержания для отображения",
|
||||
"generating": "Генерация",
|
||||
|
||||
@ -916,6 +916,11 @@
|
||||
"title": "话题搜索"
|
||||
},
|
||||
"html_artifacts": {
|
||||
"capture": {
|
||||
"label": "捕获页面",
|
||||
"to_clipboard": "复制到剪贴板",
|
||||
"to_file": "保存为图片"
|
||||
},
|
||||
"code": "代码",
|
||||
"empty_preview": "无内容可展示",
|
||||
"generating": "生成中",
|
||||
|
||||
@ -916,6 +916,11 @@
|
||||
"title": "搜尋話題"
|
||||
},
|
||||
"html_artifacts": {
|
||||
"capture": {
|
||||
"label": "捕獲頁面",
|
||||
"to_clipboard": "複製到剪貼簿",
|
||||
"to_file": "保存為圖片"
|
||||
},
|
||||
"code": "程式碼",
|
||||
"empty_preview": "無內容可展示",
|
||||
"generating": "生成中",
|
||||
|
||||
@ -916,6 +916,11 @@
|
||||
"title": "Αναζήτηση θεμάτων"
|
||||
},
|
||||
"html_artifacts": {
|
||||
"capture": {
|
||||
"label": "Καταγραφή σελίδας",
|
||||
"to_clipboard": "Αντιγραφή στο πρόχειρο",
|
||||
"to_file": "Αποθήκευση ως εικόνα"
|
||||
},
|
||||
"code": "Κώδικας",
|
||||
"empty_preview": "Δεν υπάρχει περιεχόμενο για εμφάνιση",
|
||||
"generating": "Δημιουργία",
|
||||
|
||||
@ -916,6 +916,11 @@
|
||||
"title": "Búsqueda de temas"
|
||||
},
|
||||
"html_artifacts": {
|
||||
"capture": {
|
||||
"label": "Capturar página",
|
||||
"to_clipboard": "Copiar al portapapeles",
|
||||
"to_file": "Guardar como imagen"
|
||||
},
|
||||
"code": "Código",
|
||||
"empty_preview": "Sin contenido para mostrar",
|
||||
"generating": "Generando",
|
||||
|
||||
@ -916,6 +916,11 @@
|
||||
"title": "Recherche de sujets"
|
||||
},
|
||||
"html_artifacts": {
|
||||
"capture": {
|
||||
"label": "Capturer la page",
|
||||
"to_clipboard": "Copier dans le presse-papiers",
|
||||
"to_file": "Enregistrer en tant qu'image"
|
||||
},
|
||||
"code": "Code",
|
||||
"empty_preview": "Aucun contenu à afficher",
|
||||
"generating": "Génération",
|
||||
|
||||
@ -916,6 +916,11 @@
|
||||
"title": "Procurar Tópicos"
|
||||
},
|
||||
"html_artifacts": {
|
||||
"capture": {
|
||||
"label": "Capturar página",
|
||||
"to_clipboard": "Copiar para a área de transferência",
|
||||
"to_file": "Salvar como imagem"
|
||||
},
|
||||
"code": "Código",
|
||||
"empty_preview": "Sem conteúdo para exibir",
|
||||
"generating": "Gerando",
|
||||
|
||||
@ -20,7 +20,7 @@ import { selectMessagesForTopic } from '@renderer/store/newMessage'
|
||||
import { TraceIcon } from '@renderer/trace/pages/Component'
|
||||
import type { Assistant, Model, Topic, TranslateLanguage } from '@renderer/types'
|
||||
import { type Message, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL, classNames } from '@renderer/utils'
|
||||
import { captureScrollableAsBlob, captureScrollableAsDataURL, classNames } from '@renderer/utils'
|
||||
import { copyMessageAsPlainText } from '@renderer/utils/copy'
|
||||
import {
|
||||
exportMarkdownToJoplin,
|
||||
@ -153,7 +153,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
await resendMessage(messageUpdate ?? message, assistant)
|
||||
}
|
||||
},
|
||||
[assistant, loading, message, resendMessage, topic.prompt]
|
||||
[assistant, loading, message, resendMessage]
|
||||
)
|
||||
|
||||
const { startEditing } = useMessageEditing()
|
||||
@ -271,7 +271,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
label: t('chat.topics.copy.image'),
|
||||
key: 'img',
|
||||
onClick: async () => {
|
||||
await captureScrollableDivAsBlob(messageContainerRef, async (blob) => {
|
||||
await captureScrollableAsBlob(messageContainerRef, async (blob) => {
|
||||
if (blob) {
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
||||
}
|
||||
@ -282,7 +282,7 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
label: t('chat.topics.export.image'),
|
||||
key: 'image',
|
||||
onClick: async () => {
|
||||
const imageData = await captureScrollableDivAsDataURL(messageContainerRef)
|
||||
const imageData = await captureScrollableAsDataURL(messageContainerRef)
|
||||
const title = await getMessageTitle(message)
|
||||
if (title && imageData) {
|
||||
window.api.file.saveImage(title, imageData)
|
||||
|
||||
@ -23,8 +23,8 @@ import { saveMessageAndBlocksToDB, updateMessageAndBlocksThunk } from '@renderer
|
||||
import type { Assistant, Topic } from '@renderer/types'
|
||||
import { type Message, MessageBlock, MessageBlockType } from '@renderer/types/newMessage'
|
||||
import {
|
||||
captureScrollableDivAsBlob,
|
||||
captureScrollableDivAsDataURL,
|
||||
captureScrollableAsBlob,
|
||||
captureScrollableAsDataURL,
|
||||
removeSpecialCharactersForFileName,
|
||||
runAsyncFunction
|
||||
} from '@renderer/utils'
|
||||
@ -135,14 +135,14 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
|
||||
})
|
||||
}),
|
||||
EventEmitter.on(EVENT_NAMES.COPY_TOPIC_IMAGE, async () => {
|
||||
await captureScrollableDivAsBlob(scrollContainerRef, async (blob) => {
|
||||
await captureScrollableAsBlob(scrollContainerRef, async (blob) => {
|
||||
if (blob) {
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
|
||||
}
|
||||
})
|
||||
}),
|
||||
EventEmitter.on(EVENT_NAMES.EXPORT_TOPIC_IMAGE, async () => {
|
||||
const imageData = await captureScrollableDivAsDataURL(scrollContainerRef)
|
||||
const imageData = await captureScrollableAsDataURL(scrollContainerRef)
|
||||
if (imageData) {
|
||||
window.api.file.saveImage(removeSpecialCharactersForFileName(topic.name), imageData)
|
||||
}
|
||||
|
||||
@ -7,7 +7,8 @@ import {
|
||||
addImageFileToContents,
|
||||
encodeHTML,
|
||||
escapeDollarNumber,
|
||||
extractTitle,
|
||||
extractHtmlTitle,
|
||||
getFileNameFromHtmlTitle,
|
||||
removeSvgEmptyLines,
|
||||
withGenerateImage
|
||||
} from '../formats'
|
||||
@ -179,39 +180,65 @@ describe('formats', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractTitle', () => {
|
||||
describe('extractHtmlTitle', () => {
|
||||
it('should extract title from HTML string', () => {
|
||||
const html = '<html><head><title>Page Title</title></head><body>Content</body></html>'
|
||||
expect(extractTitle(html)).toBe('Page Title')
|
||||
expect(extractHtmlTitle(html)).toBe('Page Title')
|
||||
})
|
||||
|
||||
it('should extract title with case insensitivity', () => {
|
||||
const html = '<html><head><TITLE>Page Title</TITLE></head><body>Content</body></html>'
|
||||
expect(extractTitle(html)).toBe('Page Title')
|
||||
expect(extractHtmlTitle(html)).toBe('Page Title')
|
||||
})
|
||||
|
||||
it('should handle HTML without title tag', () => {
|
||||
const html = '<html><head></head><body>Content</body></html>'
|
||||
expect(extractTitle(html)).toBeNull()
|
||||
expect(extractHtmlTitle(html)).toBe('')
|
||||
})
|
||||
|
||||
it('should handle empty title tag', () => {
|
||||
const html = '<html><head><title></title></head><body>Content</body></html>'
|
||||
expect(extractTitle(html)).toBe('')
|
||||
expect(extractHtmlTitle(html)).toBe('')
|
||||
})
|
||||
|
||||
it('should handle malformed HTML', () => {
|
||||
const html = '<title>Partial HTML'
|
||||
expect(extractTitle(html)).toBe('Partial HTML')
|
||||
expect(extractHtmlTitle(html)).toBe('Partial HTML')
|
||||
})
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(extractTitle('')).toBeNull()
|
||||
expect(extractHtmlTitle('')).toBe('')
|
||||
})
|
||||
|
||||
it('should handle undefined', () => {
|
||||
// @ts-ignore for testing
|
||||
expect(extractTitle(undefined)).toBeNull()
|
||||
expect(extractHtmlTitle(undefined)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFileNameFromHtmlTitle', () => {
|
||||
it('should preserve Chinese characters', () => {
|
||||
expect(getFileNameFromHtmlTitle('中文标题')).toBe('中文标题')
|
||||
expect(getFileNameFromHtmlTitle('中文标题 测试')).toBe('中文标题-测试')
|
||||
})
|
||||
|
||||
it('should preserve alphanumeric characters', () => {
|
||||
expect(getFileNameFromHtmlTitle('Hello123')).toBe('Hello123')
|
||||
expect(getFileNameFromHtmlTitle('Hello World 123')).toBe('Hello-World-123')
|
||||
})
|
||||
|
||||
it('should remove special characters and replace spaces with hyphens', () => {
|
||||
expect(getFileNameFromHtmlTitle('File@Name#Test')).toBe('FileNameTest')
|
||||
expect(getFileNameFromHtmlTitle('File Name Test')).toBe('File-Name-Test')
|
||||
})
|
||||
|
||||
it('should handle mixed languages', () => {
|
||||
expect(getFileNameFromHtmlTitle('中文English123')).toBe('中文English123')
|
||||
expect(getFileNameFromHtmlTitle('中文 English 123')).toBe('中文-English-123')
|
||||
})
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(getFileNameFromHtmlTitle('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
captureDiv,
|
||||
captureScrollableDiv,
|
||||
captureScrollableDivAsBlob,
|
||||
captureScrollableDivAsDataURL,
|
||||
captureElement,
|
||||
captureScrollable,
|
||||
captureScrollableAsBlob,
|
||||
captureScrollableAsDataURL,
|
||||
compressImage,
|
||||
convertToBase64,
|
||||
makeSvgSizeAdaptive
|
||||
@ -49,34 +49,34 @@ describe('utils/image', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureDiv', () => {
|
||||
it('should return image data url when divRef.current exists', async () => {
|
||||
describe('captureElement', () => {
|
||||
it('should return image data url when elRef.current exists', async () => {
|
||||
const ref = { current: document.createElement('div') } as React.RefObject<HTMLDivElement>
|
||||
const result = await captureDiv(ref)
|
||||
const result = await captureElement(ref)
|
||||
expect(result).toMatch(/^data:image\/png;base64/)
|
||||
})
|
||||
|
||||
it('should return undefined when divRef.current is null', async () => {
|
||||
it('should return undefined when elRef.current is null', async () => {
|
||||
const ref = { current: null } as unknown as React.RefObject<HTMLDivElement>
|
||||
const result = await captureDiv(ref)
|
||||
const result = await captureElement(ref)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureScrollableDiv', () => {
|
||||
it('should return canvas when divRef.current exists', async () => {
|
||||
describe('captureScrollable', () => {
|
||||
it('should return canvas when elRef.current exists', async () => {
|
||||
const div = document.createElement('div')
|
||||
Object.defineProperty(div, 'scrollWidth', { value: 100, configurable: true })
|
||||
Object.defineProperty(div, 'scrollHeight', { value: 100, configurable: true })
|
||||
const ref = { current: div } as React.RefObject<HTMLDivElement>
|
||||
const result = await captureScrollableDiv(ref)
|
||||
const result = await captureScrollable(ref)
|
||||
expect(result).toBeTruthy()
|
||||
expect(typeof (result as HTMLCanvasElement).toDataURL).toBe('function')
|
||||
})
|
||||
|
||||
it('should return undefined when divRef.current is null', async () => {
|
||||
it('should return undefined when elRef.current is null', async () => {
|
||||
const ref = { current: null } as unknown as React.RefObject<HTMLDivElement>
|
||||
const result = await captureScrollableDiv(ref)
|
||||
const result = await captureScrollable(ref)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
@ -85,36 +85,36 @@ describe('utils/image', () => {
|
||||
Object.defineProperty(div, 'scrollWidth', { value: 40000, configurable: true })
|
||||
Object.defineProperty(div, 'scrollHeight', { value: 40000, configurable: true })
|
||||
const ref = { current: div } as React.RefObject<HTMLDivElement>
|
||||
await expect(captureScrollableDiv(ref)).rejects.toBeUndefined()
|
||||
await expect(captureScrollable(ref)).rejects.toBeUndefined()
|
||||
expect(window.message.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureScrollableDivAsDataURL', () => {
|
||||
describe('captureScrollableAsDataURL', () => {
|
||||
it('should return data url when canvas exists', async () => {
|
||||
const div = document.createElement('div')
|
||||
Object.defineProperty(div, 'scrollWidth', { value: 100, configurable: true })
|
||||
Object.defineProperty(div, 'scrollHeight', { value: 100, configurable: true })
|
||||
const ref = { current: div } as React.RefObject<HTMLDivElement>
|
||||
const result = await captureScrollableDivAsDataURL(ref)
|
||||
const result = await captureScrollableAsDataURL(ref)
|
||||
expect(result).toMatch(/^data:image\/png;base64/)
|
||||
})
|
||||
|
||||
it('should return undefined when canvas is undefined', async () => {
|
||||
const ref = { current: null } as unknown as React.RefObject<HTMLDivElement>
|
||||
const result = await captureScrollableDivAsDataURL(ref)
|
||||
const result = await captureScrollableAsDataURL(ref)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureScrollableDivAsBlob', () => {
|
||||
describe('captureScrollableAsBlob', () => {
|
||||
it('should call func with blob when canvas exists', async () => {
|
||||
const div = document.createElement('div')
|
||||
Object.defineProperty(div, 'scrollWidth', { value: 100, configurable: true })
|
||||
Object.defineProperty(div, 'scrollHeight', { value: 100, configurable: true })
|
||||
const ref = { current: div } as React.RefObject<HTMLDivElement>
|
||||
const func = vi.fn()
|
||||
await captureScrollableDivAsBlob(ref, func)
|
||||
await captureScrollableAsBlob(ref, func)
|
||||
expect(func).toHaveBeenCalled()
|
||||
expect(func.mock.calls[0][0]).toBeInstanceOf(Blob)
|
||||
})
|
||||
@ -122,7 +122,7 @@ describe('utils/image', () => {
|
||||
it('should not call func when canvas is undefined', async () => {
|
||||
const ref = { current: null } as unknown as React.RefObject<HTMLDivElement>
|
||||
const func = vi.fn()
|
||||
await captureScrollableDivAsBlob(ref, func)
|
||||
await captureScrollableAsBlob(ref, func)
|
||||
expect(func).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -53,8 +53,8 @@ export function escapeDollarNumber(text: string) {
|
||||
return escapedText
|
||||
}
|
||||
|
||||
export function extractTitle(html: string): string | null {
|
||||
if (!html) return null
|
||||
export function extractHtmlTitle(html: string): string {
|
||||
if (!html) return ''
|
||||
|
||||
// 处理标准闭合的标题标签
|
||||
const titleRegex = /<title>(.*?)<\/title>/i
|
||||
@ -72,7 +72,17 @@ export function extractTitle(html: string): string | null {
|
||||
return malformedMatch[1] ? malformedMatch[1].trim() : ''
|
||||
}
|
||||
|
||||
return null
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 HTML 标题中提取文件名(不包含扩展名)
|
||||
* @param title HTML 标题
|
||||
* @returns 文件名
|
||||
*/
|
||||
export function getFileNameFromHtmlTitle(title: string): string {
|
||||
if (!title) return ''
|
||||
return title.replace(/[^\p{L}\p{N}\s]/gu, '').replace(/\s+/g, '-')
|
||||
}
|
||||
|
||||
export function removeSvgEmptyLines(text: string): string {
|
||||
|
||||
@ -33,18 +33,18 @@ export const compressImage = async (file: File): Promise<File> => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 捕获指定 div 元素的图像数据。
|
||||
* @param divRef div 元素的引用
|
||||
* 捕获指定元素的图像数据。
|
||||
* @param elRef 元素的引用
|
||||
* @returns Promise<string | undefined> 图像数据 URL,如果失败则返回 undefined
|
||||
*/
|
||||
export async function captureDiv(divRef: React.RefObject<HTMLDivElement>) {
|
||||
if (divRef.current) {
|
||||
export async function captureElement(elRef: React.RefObject<HTMLElement>) {
|
||||
if (elRef.current) {
|
||||
try {
|
||||
const canvas = await htmlToImage.toCanvas(divRef.current)
|
||||
const canvas = await htmlToImage.toCanvas(elRef.current)
|
||||
const imageData = canvas.toDataURL('image/png')
|
||||
return imageData
|
||||
} catch (error) {
|
||||
logger.error('Error capturing div:', error as Error)
|
||||
logger.error('Error capturing element:', error as Error)
|
||||
return Promise.reject()
|
||||
}
|
||||
}
|
||||
@ -52,50 +52,50 @@ export async function captureDiv(divRef: React.RefObject<HTMLDivElement>) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 捕获可滚动 div 元素的完整内容图像。
|
||||
* @param divRef 可滚动 div 元素的引用
|
||||
* 捕获可滚动元素的完整内容图像。
|
||||
* @param elRef 可滚动元素的引用
|
||||
* @returns Promise<HTMLCanvasElement | undefined> 捕获的画布对象,如果失败则返回 undefined
|
||||
*/
|
||||
export const captureScrollableDiv = async (divRef: React.RefObject<HTMLDivElement | null>) => {
|
||||
if (divRef.current) {
|
||||
export const captureScrollable = async (elRef: React.RefObject<HTMLElement | null>) => {
|
||||
if (elRef.current) {
|
||||
try {
|
||||
const div = divRef.current
|
||||
const el = elRef.current
|
||||
|
||||
// Save original styles
|
||||
const originalStyle = {
|
||||
height: div.style.height,
|
||||
maxHeight: div.style.maxHeight,
|
||||
overflow: div.style.overflow,
|
||||
position: div.style.position
|
||||
height: el.style.height,
|
||||
maxHeight: el.style.maxHeight,
|
||||
overflow: el.style.overflow,
|
||||
position: el.style.position
|
||||
}
|
||||
|
||||
const originalScrollTop = div.scrollTop
|
||||
const originalScrollTop = el.scrollTop
|
||||
|
||||
// Hide scrollbars during capture
|
||||
div.classList.add('hide-scrollbar')
|
||||
el.classList.add('hide-scrollbar')
|
||||
|
||||
// Modify styles to show full content
|
||||
div.style.height = 'auto'
|
||||
div.style.maxHeight = 'none'
|
||||
div.style.overflow = 'visible'
|
||||
div.style.position = 'static'
|
||||
el.style.height = 'auto'
|
||||
el.style.maxHeight = 'none'
|
||||
el.style.overflow = 'visible'
|
||||
el.style.position = 'static'
|
||||
|
||||
// calculate the size of the div
|
||||
const totalWidth = div.scrollWidth
|
||||
const totalHeight = div.scrollHeight
|
||||
// calculate the size of the element
|
||||
const totalWidth = el.scrollWidth
|
||||
const totalHeight = el.scrollHeight
|
||||
|
||||
// check if the size of the div is too large
|
||||
// check if the size of the element is too large
|
||||
const MAX_ALLOWED_DIMENSION = 32767 // the maximum allowed pixel size
|
||||
if (totalHeight > MAX_ALLOWED_DIMENSION || totalWidth > MAX_ALLOWED_DIMENSION) {
|
||||
// restore the original styles
|
||||
div.style.height = originalStyle.height
|
||||
div.style.maxHeight = originalStyle.maxHeight
|
||||
div.style.overflow = originalStyle.overflow
|
||||
div.style.position = originalStyle.position
|
||||
el.style.height = originalStyle.height
|
||||
el.style.maxHeight = originalStyle.maxHeight
|
||||
el.style.overflow = originalStyle.overflow
|
||||
el.style.position = originalStyle.position
|
||||
|
||||
// restore the original scroll position
|
||||
setTimeout(() => {
|
||||
div.scrollTop = originalScrollTop
|
||||
el.scrollTop = originalScrollTop
|
||||
}, 0)
|
||||
|
||||
window.message.error({
|
||||
@ -107,16 +107,16 @@ export const captureScrollableDiv = async (divRef: React.RefObject<HTMLDivElemen
|
||||
|
||||
const canvas = await new Promise<HTMLCanvasElement>((resolve, reject) => {
|
||||
htmlToImage
|
||||
.toCanvas(div, {
|
||||
backgroundColor: getComputedStyle(div).getPropertyValue('--color-background'),
|
||||
.toCanvas(el, {
|
||||
backgroundColor: getComputedStyle(el).getPropertyValue('--color-background'),
|
||||
cacheBust: true,
|
||||
pixelRatio: window.devicePixelRatio,
|
||||
skipAutoScale: true,
|
||||
canvasWidth: div.scrollWidth,
|
||||
canvasHeight: div.scrollHeight,
|
||||
canvasWidth: el.scrollWidth,
|
||||
canvasHeight: el.scrollHeight,
|
||||
style: {
|
||||
backgroundColor: getComputedStyle(div).backgroundColor,
|
||||
color: getComputedStyle(div).color
|
||||
backgroundColor: getComputedStyle(el).backgroundColor,
|
||||
color: getComputedStyle(el).color
|
||||
}
|
||||
})
|
||||
.then((canvas) => resolve(canvas))
|
||||
@ -124,25 +124,25 @@ export const captureScrollableDiv = async (divRef: React.RefObject<HTMLDivElemen
|
||||
})
|
||||
|
||||
// Restore original styles
|
||||
div.style.height = originalStyle.height
|
||||
div.style.maxHeight = originalStyle.maxHeight
|
||||
div.style.overflow = originalStyle.overflow
|
||||
div.style.position = originalStyle.position
|
||||
el.style.height = originalStyle.height
|
||||
el.style.maxHeight = originalStyle.maxHeight
|
||||
el.style.overflow = originalStyle.overflow
|
||||
el.style.position = originalStyle.position
|
||||
|
||||
const imageData = canvas
|
||||
|
||||
// Restore original scroll position
|
||||
setTimeout(() => {
|
||||
div.scrollTop = originalScrollTop
|
||||
el.scrollTop = originalScrollTop
|
||||
}, 0)
|
||||
|
||||
return imageData
|
||||
} catch (error) {
|
||||
logger.error('Error capturing scrollable div:', error as Error)
|
||||
logger.error('Error capturing scrollable element:', error as Error)
|
||||
throw error
|
||||
} finally {
|
||||
// Remove scrollbar hiding class
|
||||
divRef.current?.classList.remove('hide-scrollbar')
|
||||
elRef.current?.classList.remove('hide-scrollbar')
|
||||
}
|
||||
}
|
||||
|
||||
@ -150,12 +150,12 @@ export const captureScrollableDiv = async (divRef: React.RefObject<HTMLDivElemen
|
||||
}
|
||||
|
||||
/**
|
||||
* 将可滚动 div 元素的图像数据转换为 Data URL 格式。
|
||||
* @param divRef 可滚动 div 元素的引用
|
||||
* 将可滚动元素的图像数据转换为 Data URL 格式。
|
||||
* @param elRef 可滚动元素的引用
|
||||
* @returns Promise<string | undefined> 图像数据 URL,如果失败则返回 undefined
|
||||
*/
|
||||
export const captureScrollableDivAsDataURL = async (divRef: React.RefObject<HTMLDivElement | null>) => {
|
||||
return captureScrollableDiv(divRef).then((canvas) => {
|
||||
export const captureScrollableAsDataURL = async (elRef: React.RefObject<HTMLElement | null>) => {
|
||||
return captureScrollable(elRef).then((canvas) => {
|
||||
if (canvas) {
|
||||
return canvas.toDataURL('image/png')
|
||||
}
|
||||
@ -164,16 +164,94 @@ export const captureScrollableDivAsDataURL = async (divRef: React.RefObject<HTML
|
||||
}
|
||||
|
||||
/**
|
||||
* 将可滚动 div 元素的图像数据转换为 Blob 格式。
|
||||
* @param divRef 可滚动 div 元素的引用
|
||||
* 将可滚动元素的图像数据转换为 Blob 格式。
|
||||
* @param elRef 可滚动元素的引用
|
||||
* @param func Blob 回调函数
|
||||
* @returns Promise<void> 处理结果
|
||||
*/
|
||||
export const captureScrollableDivAsBlob = async (
|
||||
divRef: React.RefObject<HTMLDivElement | null>,
|
||||
export const captureScrollableAsBlob = async (elRef: React.RefObject<HTMLElement | null>, func: BlobCallback) => {
|
||||
await captureScrollable(elRef).then((canvas) => {
|
||||
canvas?.toBlob(func, 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 捕获 iframe 内部文档的完整内容快照
|
||||
*/
|
||||
export async function captureScrollableIframe(
|
||||
iframeRef: React.RefObject<HTMLIFrameElement | null>
|
||||
): Promise<HTMLCanvasElement | undefined> {
|
||||
const iframe = iframeRef.current
|
||||
if (!iframe) return Promise.resolve(undefined)
|
||||
|
||||
const doc = iframe.contentDocument
|
||||
const win = doc?.defaultView
|
||||
if (!doc || !win) return Promise.resolve(undefined)
|
||||
|
||||
// 等待两帧渲染稳定
|
||||
await new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())))
|
||||
|
||||
// 触发懒加载资源尽快加载
|
||||
doc.querySelectorAll('img[loading="lazy"]').forEach((img) => img.setAttribute('loading', 'eager'))
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
|
||||
const de = doc.documentElement
|
||||
const b = doc.body
|
||||
|
||||
// 计算完整尺寸
|
||||
const totalWidth = Math.max(b.scrollWidth, de.scrollWidth, b.clientWidth, de.clientWidth)
|
||||
const totalHeight = Math.max(b.scrollHeight, de.scrollHeight, b.clientHeight, de.clientHeight)
|
||||
|
||||
logger.verbose('The iframe to be captured has size:', { totalWidth, totalHeight })
|
||||
|
||||
// 按比例缩放以不超过上限
|
||||
const MAX = 32767
|
||||
const maxSide = Math.max(totalWidth, totalHeight)
|
||||
const scale = maxSide > MAX ? MAX / maxSide : 1
|
||||
const pixelRatio = (win.devicePixelRatio || 1) * scale
|
||||
|
||||
const bg = win.getComputedStyle(b).backgroundColor || '#ffffff'
|
||||
const fg = win.getComputedStyle(b).color || '#000000'
|
||||
|
||||
try {
|
||||
const canvas = await htmlToImage.toCanvas(de, {
|
||||
backgroundColor: bg,
|
||||
cacheBust: true,
|
||||
pixelRatio,
|
||||
skipAutoScale: true,
|
||||
width: Math.floor(totalWidth),
|
||||
height: Math.floor(totalHeight),
|
||||
style: {
|
||||
backgroundColor: bg,
|
||||
color: fg,
|
||||
width: `${totalWidth}px`,
|
||||
height: `${totalHeight}px`,
|
||||
overflow: 'visible',
|
||||
display: 'block'
|
||||
}
|
||||
})
|
||||
|
||||
return canvas
|
||||
} catch (error) {
|
||||
logger.error('Error capturing iframe full snapshot:', error as Error)
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
export const captureScrollableIframeAsDataURL = async (iframeRef: React.RefObject<HTMLIFrameElement | null>) => {
|
||||
return captureScrollableIframe(iframeRef).then((canvas) => {
|
||||
if (canvas) {
|
||||
return canvas.toDataURL('image/png')
|
||||
}
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
}
|
||||
|
||||
export const captureScrollableIframeAsBlob = async (
|
||||
iframeRef: React.RefObject<HTMLIFrameElement | null>,
|
||||
func: BlobCallback
|
||||
) => {
|
||||
await captureScrollableDiv(divRef).then((canvas) => {
|
||||
await captureScrollableIframe(iframeRef).then((canvas) => {
|
||||
canvas?.toBlob(func, 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user