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:
one 2025-08-28 21:22:56 +08:00 committed by GitHub
parent 46e731dee0
commit 649a2a645c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 333 additions and 106 deletions

View File

@ -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' })
}

View File

@ -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

View File

@ -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) || ''
}
// 默认使用日期格式命名

View File

@ -1,7 +1,7 @@
import { CSSProperties, SVGProps } from 'react'
interface BaseFileIconProps extends SVGProps<SVGSVGElement> {
size?: string
size?: string | number
text?: string
}

View File

@ -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",

View File

@ -916,6 +916,11 @@
"title": "トピック検索"
},
"html_artifacts": {
"capture": {
"label": "ページをキャプチャ",
"to_clipboard": "クリップボードにコピー",
"to_file": "画像として保存"
},
"code": "コード",
"empty_preview": "表示するコンテンツがありません",
"generating": "生成中",

View File

@ -916,6 +916,11 @@
"title": "Поиск топиков"
},
"html_artifacts": {
"capture": {
"label": "Захват страницы",
"to_clipboard": "Копировать в буфер обмена",
"to_file": "Сохранить как изображение"
},
"code": "Код",
"empty_preview": "Нет содержания для отображения",
"generating": "Генерация",

View File

@ -916,6 +916,11 @@
"title": "话题搜索"
},
"html_artifacts": {
"capture": {
"label": "捕获页面",
"to_clipboard": "复制到剪贴板",
"to_file": "保存为图片"
},
"code": "代码",
"empty_preview": "无内容可展示",
"generating": "生成中",

View File

@ -916,6 +916,11 @@
"title": "搜尋話題"
},
"html_artifacts": {
"capture": {
"label": "捕獲頁面",
"to_clipboard": "複製到剪貼簿",
"to_file": "保存為圖片"
},
"code": "程式碼",
"empty_preview": "無內容可展示",
"generating": "生成中",

View File

@ -916,6 +916,11 @@
"title": "Αναζήτηση θεμάτων"
},
"html_artifacts": {
"capture": {
"label": "Καταγραφή σελίδας",
"to_clipboard": "Αντιγραφή στο πρόχειρο",
"to_file": "Αποθήκευση ως εικόνα"
},
"code": "Κώδικας",
"empty_preview": "Δεν υπάρχει περιεχόμενο για εμφάνιση",
"generating": "Δημιουργία",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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)

View File

@ -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)
}

View File

@ -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('')
})
})

View File

@ -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()
})
})

View File

@ -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 {

View File

@ -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')
})
}