mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
fix(capture): restore AI screenshot framing
This commit is contained in:
1 parent
1b940143fd
commit
3c8b83d571
8 files changed
+173
-185
No files matched your search
+1
-1
@@ -53,7 +53,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/vue-virtual": "^3.13.18",
|
||||
"@zumer/snapdom": "^2.0.1",
|
||||
"@zumer/snapdom": "^2.16.0",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-wordcloud": "^2.1.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
|
||||
Generated
+7
-22
@@ -12,8 +12,8 @@ importers:
|
||||
specifier: ^3.13.18
|
||||
version: 3.13.18(vue@3.5.27(typescript@6.0.3))
|
||||
'@zumer/snapdom':
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.2
|
||||
specifier: ^2.16.0
|
||||
version: 2.16.0
|
||||
echarts:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
@@ -1321,15 +1321,9 @@ packages:
|
||||
'@fastify/static@9.1.3':
|
||||
resolution: {integrity: sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==}
|
||||
|
||||
'@floating-ui/core@1.7.4':
|
||||
resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==}
|
||||
|
||||
'@floating-ui/core@1.8.0':
|
||||
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
|
||||
|
||||
'@floating-ui/dom@1.7.5':
|
||||
resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==}
|
||||
|
||||
'@floating-ui/dom@1.8.0':
|
||||
resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==}
|
||||
|
||||
@@ -3371,8 +3365,8 @@ packages:
|
||||
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
'@zumer/snapdom@2.0.2':
|
||||
resolution: {integrity: sha512-W6quT4lMcPu8Q9O/Q6witSfc6/+xuY8C8yDoHug/+o7zYKCNE/e0I3//XsWDkyq9C0mDE0TAWF/8bwCR7x3gHQ==}
|
||||
'@zumer/snapdom@2.16.0':
|
||||
resolution: {integrity: sha512-ZfN3o6kcxb+jDhYe/JHFZf3g4UfDv3MGyTiK/sWvFU+vYlxa2KZVLZH7DxhKkobwGp66KMhRnXTqIKmaSgM50Q==}
|
||||
|
||||
abbrev@4.0.0:
|
||||
resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==}
|
||||
@@ -7966,19 +7960,10 @@ snapshots:
|
||||
fastq: 1.20.1
|
||||
glob: 13.0.6
|
||||
|
||||
'@floating-ui/core@1.7.4':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
'@floating-ui/core@1.8.0':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.12
|
||||
|
||||
'@floating-ui/dom@1.7.5':
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.4
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
'@floating-ui/dom@1.8.0':
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.8.0
|
||||
@@ -9412,7 +9397,7 @@ snapshots:
|
||||
|
||||
'@tiptap/extension-bubble-menu@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.5
|
||||
'@floating-ui/dom': 1.8.0
|
||||
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
|
||||
'@tiptap/pm': 3.19.0
|
||||
|
||||
@@ -9449,7 +9434,7 @@ snapshots:
|
||||
|
||||
'@tiptap/extension-drag-handle@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/extension-collaboration@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29))(@tiptap/extension-node-range@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.5
|
||||
'@floating-ui/dom': 1.8.0
|
||||
'@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)
|
||||
'@tiptap/extension-collaboration': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29)
|
||||
'@tiptap/extension-node-range': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)
|
||||
@@ -10055,7 +10040,7 @@ snapshots:
|
||||
|
||||
'@xmldom/xmldom@0.8.13': {}
|
||||
|
||||
'@zumer/snapdom@2.0.2': {}
|
||||
'@zumer/snapdom@2.16.0': {}
|
||||
|
||||
abbrev@4.0.0: {}
|
||||
|
||||
|
||||
@@ -1028,6 +1028,7 @@ async function handleCopyMarkdown() {
|
||||
type="element"
|
||||
target-selector=".qa-pair"
|
||||
markdown-fix
|
||||
capture-frame
|
||||
:progressive-narrowing="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,8 @@ const props = withDefaults(
|
||||
markdownFix?: boolean
|
||||
/** 是否对截图内容进行渐进式缩窄;传入数字时作为基准宽度 */
|
||||
progressiveNarrowing?: number | boolean
|
||||
/** 是否为截图增加外框留白 */
|
||||
captureFrame?: boolean
|
||||
/** 按钮颜色,默认 primary */
|
||||
color?: string
|
||||
}>(),
|
||||
@@ -50,6 +52,7 @@ async function handleCapture(event: Event) {
|
||||
const defaultOptions = {
|
||||
hideSelectors: [`#${buttonId.value}`],
|
||||
progressiveNarrowing: props.progressiveNarrowing,
|
||||
captureFrame: props.captureFrame,
|
||||
markdownFix: props.markdownFix || undefined,
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { captureAsImageData } from '@/utils/snapCapture'
|
||||
import { waitForCaptureLayoutStabilization } from '@/utils/captureLayout'
|
||||
import { resolveCaptureBoxSizing, waitForCaptureLayoutStabilization } from '@/utils/captureLayout'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useLayoutStore } from '@/stores/layout'
|
||||
import { usePlatformService } from '@/services'
|
||||
import { useCacheService } from '@/services/cache/service'
|
||||
|
||||
/** 渐进式缩窄的默认基准宽度 */
|
||||
const DEFAULT_NARROWING_BASE_WIDTH = 525
|
||||
const CAPTURE_FRAME_HORIZONTAL_PADDING = 40
|
||||
const CAPTURE_FRAME_VERTICAL_PADDING = 16
|
||||
|
||||
export interface ScreenCaptureOptions {
|
||||
/** 截屏时要隐藏的元素选择器列表 */
|
||||
@@ -29,6 +29,8 @@ export interface ScreenCaptureOptions {
|
||||
* - 传入 false 或不传:不进行缩窄
|
||||
*/
|
||||
progressiveNarrowing?: number | boolean
|
||||
/** 是否为截图增加外框留白;留白不会额外压缩内容宽度 */
|
||||
captureFrame?: boolean
|
||||
/** 是否应用 Markdown 列表渲染兼容修复(仅截取 Markdown 内容时需要,默认 false) */
|
||||
markdownFix?: boolean
|
||||
}
|
||||
@@ -134,40 +136,37 @@ export function useScreenCapture() {
|
||||
isCapturing.value = true
|
||||
captureError.value = null
|
||||
|
||||
// 只在底部预留水印空间,避免横向 padding 改变卡片内容宽度并触发图表重排。
|
||||
// 截图外框只由显式选项开启,普通卡片截图不改变原布局。
|
||||
const originalPadding = element.style.padding
|
||||
const originalPaddingBottom = element.style.paddingBottom
|
||||
const originalBoxSizing = element.style.boxSizing
|
||||
const originalPosition = element.style.position
|
||||
const originalWidth = element.style.width
|
||||
const originalMinWidth = element.style.minWidth
|
||||
const originalMaxWidth = element.style.maxWidth
|
||||
|
||||
const currentWidth = element.getBoundingClientRect().width
|
||||
const captureBoxSizing = resolveCaptureBoxSizing({
|
||||
currentWidth,
|
||||
progressiveNarrowing: options?.progressiveNarrowing,
|
||||
frameHorizontalPadding: options?.captureFrame ? CAPTURE_FRAME_HORIZONTAL_PADDING : 0,
|
||||
})
|
||||
|
||||
if (options?.captureFrame) {
|
||||
element.style.padding = `${CAPTURE_FRAME_VERTICAL_PADDING}px ${CAPTURE_FRAME_HORIZONTAL_PADDING}px`
|
||||
}
|
||||
element.style.paddingBottom = '48px' // 为水印留出空间
|
||||
const computedPosition = window.getComputedStyle(element).position
|
||||
if (computedPosition === 'static') {
|
||||
element.style.position = 'relative'
|
||||
}
|
||||
|
||||
// 渐进式缩窄:只压缩超出基准宽度的部分,避免截图突然变得过窄。
|
||||
let didChangeWidth = false
|
||||
if (options?.progressiveNarrowing) {
|
||||
const baseWidth =
|
||||
typeof options.progressiveNarrowing === 'number' ? options.progressiveNarrowing : DEFAULT_NARROWING_BASE_WIDTH
|
||||
|
||||
// 获取元素当前的实际宽度
|
||||
const currentWidth = element.getBoundingClientRect().width
|
||||
|
||||
// 只有当原始宽度大于基准宽度时才缩放
|
||||
if (currentWidth > baseWidth) {
|
||||
// 渐进式缩放:目标宽度 = 基准宽度 + (原始宽度 - 基准宽度) × 缩放因子
|
||||
// 缩放因子 0.3 表示超出部分保留 30%
|
||||
const scaleFactor = 0.3
|
||||
const targetWidth = Math.round(baseWidth + (currentWidth - baseWidth) * scaleFactor)
|
||||
|
||||
element.style.width = `${targetWidth}px`
|
||||
element.style.minWidth = `${targetWidth}px`
|
||||
element.style.maxWidth = `${targetWidth}px`
|
||||
didChangeWidth = true
|
||||
}
|
||||
// 外框使用额外的根元素宽度承载,避免 40px 横向留白再次压缩正文和图表。
|
||||
if (options?.captureFrame || captureBoxSizing.didChangeContentWidth) {
|
||||
element.style.boxSizing = 'border-box'
|
||||
element.style.width = `${captureBoxSizing.outerWidth}px`
|
||||
element.style.minWidth = `${captureBoxSizing.outerWidth}px`
|
||||
element.style.maxWidth = `${captureBoxSizing.outerWidth}px`
|
||||
}
|
||||
|
||||
// 添加底部水印标识(绝对定位)
|
||||
@@ -402,7 +401,7 @@ export function useScreenCapture() {
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForCaptureLayoutStabilization({ resizeCharts: didChangeWidth })
|
||||
await waitForCaptureLayoutStabilization({ resizeCharts: captureBoxSizing.didChangeContentWidth })
|
||||
|
||||
const imageData = await captureAsImageData(element, {
|
||||
maxExportWidth: options?.maxExportWidth,
|
||||
@@ -440,7 +439,9 @@ export function useScreenCapture() {
|
||||
watermark.remove()
|
||||
|
||||
// 恢复元素样式
|
||||
element.style.padding = originalPadding
|
||||
element.style.paddingBottom = originalPaddingBottom
|
||||
element.style.boxSizing = originalBoxSizing
|
||||
element.style.position = originalPosition
|
||||
element.style.width = originalWidth
|
||||
element.style.minWidth = originalMinWidth
|
||||
@@ -484,7 +485,7 @@ export function useScreenCapture() {
|
||||
for (const el of hiddenElements) {
|
||||
el.classList.remove('__capture-hidden__')
|
||||
}
|
||||
await waitForCaptureLayoutStabilization({ resizeCharts: didChangeWidth })
|
||||
await waitForCaptureLayoutStabilization({ resizeCharts: captureBoxSizing.didChangeContentWidth })
|
||||
isCapturing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { waitForCaptureLayoutStabilization } from './captureLayout'
|
||||
import { resolveCaptureBoxSizing, waitForCaptureLayoutStabilization } from './captureLayout'
|
||||
|
||||
describe('resolveCaptureBoxSizing', () => {
|
||||
it('adds frame width without narrowing the captured content', () => {
|
||||
assert.deepEqual(
|
||||
resolveCaptureBoxSizing({
|
||||
currentWidth: 720,
|
||||
frameHorizontalPadding: 40,
|
||||
}),
|
||||
{
|
||||
contentWidth: 720,
|
||||
outerWidth: 800,
|
||||
didChangeContentWidth: false,
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps frame padding outside the progressively narrowed content', () => {
|
||||
assert.deepEqual(
|
||||
resolveCaptureBoxSizing({
|
||||
currentWidth: 900,
|
||||
progressiveNarrowing: true,
|
||||
frameHorizontalPadding: 40,
|
||||
}),
|
||||
{
|
||||
contentWidth: 638,
|
||||
outerWidth: 718,
|
||||
didChangeContentWidth: true,
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('waitForCaptureLayoutStabilization', () => {
|
||||
it('waits for layout without broadcasting resize when the capture width is unchanged', async () => {
|
||||
|
||||
@@ -1,3 +1,42 @@
|
||||
const DEFAULT_NARROWING_BASE_WIDTH = 525
|
||||
const NARROWING_SCALE_FACTOR = 0.3
|
||||
|
||||
interface CaptureBoxSizingOptions {
|
||||
currentWidth: number
|
||||
progressiveNarrowing?: number | boolean
|
||||
frameHorizontalPadding?: number
|
||||
}
|
||||
|
||||
interface CaptureBoxSizing {
|
||||
/** 截图期间留给原内容的宽度 */
|
||||
contentWidth: number
|
||||
/** 包含截图外框留白的根元素宽度 */
|
||||
outerWidth: number
|
||||
/** 原内容宽度是否发生变化,用于决定是否通知图表 resize */
|
||||
didChangeContentWidth: boolean
|
||||
}
|
||||
|
||||
export function resolveCaptureBoxSizing(options: CaptureBoxSizingOptions): CaptureBoxSizing {
|
||||
const { currentWidth, progressiveNarrowing } = options
|
||||
let contentWidth = currentWidth
|
||||
|
||||
if (progressiveNarrowing) {
|
||||
const baseWidth = typeof progressiveNarrowing === 'number' ? progressiveNarrowing : DEFAULT_NARROWING_BASE_WIDTH
|
||||
|
||||
if (currentWidth > baseWidth) {
|
||||
contentWidth = Math.round(baseWidth + (currentWidth - baseWidth) * NARROWING_SCALE_FACTOR)
|
||||
}
|
||||
}
|
||||
|
||||
const frameHorizontalPadding = Math.max(0, options.frameHorizontalPadding ?? 0)
|
||||
|
||||
return {
|
||||
contentWidth,
|
||||
outerWidth: contentWidth + frameHorizontalPadding * 2,
|
||||
didChangeContentWidth: contentWidth !== currentWidth,
|
||||
}
|
||||
}
|
||||
|
||||
interface CaptureLayoutStabilizationOptions {
|
||||
/** 仅当截图过程确实改变图表容器宽度时广播 resize */
|
||||
resizeCharts?: boolean
|
||||
|
||||
+62
-134
@@ -1,4 +1,4 @@
|
||||
import { snapdom } from '@zumer/snapdom'
|
||||
import { snapdom, type CaptureResult, type SnapdomOptions } from '@zumer/snapdom'
|
||||
|
||||
export interface CaptureOptions {
|
||||
maxExportWidth?: number
|
||||
@@ -104,6 +104,52 @@ function triggerDownload(href: string, filename: string) {
|
||||
a.remove()
|
||||
}
|
||||
|
||||
function canvasToPngBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob)
|
||||
} else {
|
||||
reject(new Error('Canvas PNG encoding returned an empty result'))
|
||||
}
|
||||
}, 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Failed to serialize captured image'))
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
async function renderCaptureToBlob(
|
||||
result: CaptureResult,
|
||||
maxExportWidth: number,
|
||||
backgroundColor: string
|
||||
): Promise<Blob> {
|
||||
const sourceCanvas = await result.toCanvas()
|
||||
let exportCanvas = sourceCanvas
|
||||
|
||||
// 大部分截图无需缩放,直接复用 SnapDOM 的 Canvas,避免一次全尺寸绘制和内存分配。
|
||||
if (sourceCanvas.width > maxExportWidth) {
|
||||
const scale = maxExportWidth / sourceCanvas.width
|
||||
const resizedCanvas = document.createElement('canvas')
|
||||
resizedCanvas.width = Math.round(sourceCanvas.width * scale)
|
||||
resizedCanvas.height = Math.round(sourceCanvas.height * scale)
|
||||
const context = resizedCanvas.getContext('2d')
|
||||
if (!context) throw new Error('Failed to create capture resize context')
|
||||
|
||||
context.fillStyle = backgroundColor
|
||||
context.fillRect(0, 0, resizedCanvas.width, resizedCanvas.height)
|
||||
context.drawImage(sourceCanvas, 0, 0, resizedCanvas.width, resizedCanvas.height)
|
||||
exportCanvas = resizedCanvas
|
||||
}
|
||||
return canvasToPngBlob(exportCanvas)
|
||||
}
|
||||
|
||||
/**
|
||||
* 捕获元素为图片数据,返回 base64 字符串
|
||||
* @param rootEl 要捕获的 DOM 元素
|
||||
@@ -124,73 +170,38 @@ export async function captureAsImageData(rootEl: HTMLElement, options?: CaptureO
|
||||
let captureScale = Math.min(1, maxExportWidth / Math.max(1, elementWidth))
|
||||
captureScale = Math.max(minScale, captureScale)
|
||||
|
||||
const snapOptions: Record<string, unknown> = {
|
||||
const snapOptions: SnapdomOptions = {
|
||||
scale: captureScale,
|
||||
// 禁用字体嵌入可以避免某些 Unicode 字符导致的 encodeURIComponent 错误
|
||||
embedFonts: options?.embedFonts ?? false,
|
||||
compress: options?.compress ?? true,
|
||||
backgroundColor: bgColor,
|
||||
crossOrigin: options?.crossOrigin ?? 'anonymous',
|
||||
}
|
||||
|
||||
const result = await (
|
||||
snapdom as (
|
||||
element: Element,
|
||||
options: Record<string, unknown>
|
||||
) => Promise<{
|
||||
toCanvas: () => Promise<unknown>
|
||||
toPng: (options?: Record<string, unknown>) => Promise<unknown>
|
||||
toImg: () => Promise<HTMLImageElement>
|
||||
}>
|
||||
)(rootEl, snapOptions)
|
||||
const result = await snapdom(rootEl, snapOptions)
|
||||
|
||||
// Preferred: Canvas path, apply background and scale again if needed, return data URL
|
||||
// 首选 Canvas + 异步 Blob 编码,避免 toDataURL 同步阻塞渲染线程。
|
||||
try {
|
||||
const canvas: unknown = await result.toCanvas()
|
||||
if (canvas && (canvas as HTMLCanvasElement).toDataURL) {
|
||||
const srcCanvas = canvas as HTMLCanvasElement
|
||||
const outCanvas = document.createElement('canvas')
|
||||
const scale2 = srcCanvas.width > maxExportWidth ? maxExportWidth / srcCanvas.width : 1
|
||||
outCanvas.width = Math.round(srcCanvas.width * scale2)
|
||||
outCanvas.height = Math.round(srcCanvas.height * scale2)
|
||||
const ctx = outCanvas.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.fillStyle = bgColor
|
||||
ctx.fillRect(0, 0, outCanvas.width, outCanvas.height)
|
||||
ctx.drawImage(srcCanvas, 0, 0, outCanvas.width, outCanvas.height)
|
||||
return outCanvas.toDataURL('image/png')
|
||||
}
|
||||
}
|
||||
const blob = await renderCaptureToBlob(result, maxExportWidth, bgColor)
|
||||
return blobToDataUrl(blob)
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
// Fallback: direct PNG export with background
|
||||
// Fallback: use SnapDOM's Blob exporter while retaining asynchronous PNG encoding.
|
||||
try {
|
||||
const png: unknown = await result.toPng({ backgroundColor: bgColor })
|
||||
|
||||
if (png instanceof HTMLImageElement) {
|
||||
return png.src
|
||||
}
|
||||
if (png instanceof Blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(png)
|
||||
})
|
||||
}
|
||||
if (typeof png === 'string') {
|
||||
return png
|
||||
}
|
||||
const blob = await result.toBlob({ type: 'png', backgroundColor: bgColor })
|
||||
return blobToDataUrl(blob)
|
||||
} catch {
|
||||
// swallow
|
||||
}
|
||||
|
||||
// Last Fallback: toImg
|
||||
// Last fallback: convert SnapDOM's PNG image result back into a Blob.
|
||||
try {
|
||||
const img: HTMLImageElement = await result.toImg()
|
||||
return img.src
|
||||
const image = await result.toPng({ backgroundColor: bgColor })
|
||||
const response = await fetch(image.src)
|
||||
const blob = await response.blob()
|
||||
return blobToDataUrl(blob)
|
||||
} catch (e) {
|
||||
console.error('captureAsImageData: all export paths failed', e)
|
||||
throw new Error('Failed to capture image data')
|
||||
@@ -198,94 +209,11 @@ export async function captureAsImageData(rootEl: HTMLElement, options?: CaptureO
|
||||
}
|
||||
|
||||
export async function captureAndDownloadPng(rootEl: HTMLElement, options?: CaptureOptions): Promise<void> {
|
||||
// 提高默认清晰度:maxExportWidth 2160(2K),minScale 1(不缩小)
|
||||
const maxExportWidth = options?.maxExportWidth ?? 2160
|
||||
const minScale = options?.minScale ?? 1
|
||||
const fullContent = options?.fullContent !== false // 默认为 true
|
||||
|
||||
// 获取元素的实际背景色(优先用户指定,否则自动检测)
|
||||
const bgColor = options?.backgroundColor ?? getEffectiveBackground(rootEl)
|
||||
|
||||
// 计算元素尺寸:如果需要完整内容,使用 scrollWidth/scrollHeight
|
||||
const elementWidth = fullContent ? rootEl.scrollWidth : rootEl.getBoundingClientRect().width
|
||||
let captureScale = Math.min(1, maxExportWidth / Math.max(1, elementWidth))
|
||||
captureScale = Math.max(minScale, captureScale)
|
||||
|
||||
const snapOptions: Record<string, unknown> = {
|
||||
scale: captureScale,
|
||||
// 禁用字体嵌入可以避免某些 Unicode 字符导致的 encodeURIComponent 错误
|
||||
embedFonts: options?.embedFonts ?? false,
|
||||
compress: options?.compress ?? true,
|
||||
backgroundColor: bgColor,
|
||||
crossOrigin: options?.crossOrigin ?? 'anonymous',
|
||||
}
|
||||
|
||||
const result = await (
|
||||
snapdom as (
|
||||
element: Element,
|
||||
options: Record<string, unknown>
|
||||
) => Promise<{
|
||||
toCanvas: () => Promise<unknown>
|
||||
toPng: (options?: Record<string, unknown>) => Promise<unknown>
|
||||
toImg: () => Promise<HTMLImageElement>
|
||||
}>
|
||||
)(rootEl, snapOptions)
|
||||
|
||||
// Preferred: Canvas path, apply background and scale again if needed, export PNG
|
||||
try {
|
||||
const canvas: unknown = await result.toCanvas()
|
||||
if (canvas && (canvas as HTMLCanvasElement).toDataURL) {
|
||||
const srcCanvas = canvas as HTMLCanvasElement
|
||||
const outCanvas = document.createElement('canvas')
|
||||
const scale2 = srcCanvas.width > maxExportWidth ? maxExportWidth / srcCanvas.width : 1
|
||||
outCanvas.width = Math.round(srcCanvas.width * scale2)
|
||||
outCanvas.height = Math.round(srcCanvas.height * scale2)
|
||||
const ctx = outCanvas.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.fillStyle = bgColor
|
||||
ctx.fillRect(0, 0, outCanvas.width, outCanvas.height)
|
||||
ctx.drawImage(srcCanvas, 0, 0, outCanvas.width, outCanvas.height)
|
||||
const url = outCanvas.toDataURL('image/png')
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
triggerDownload(url, options?.filename ?? `wlb-report-${ts}.png`)
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
// Fallback: direct PNG export with background
|
||||
try {
|
||||
const png: unknown = await result.toPng({ backgroundColor: bgColor })
|
||||
const dataUrl = await captureAsImageData(rootEl, options)
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const name = options?.filename ?? `wlb-report-${ts}.png`
|
||||
|
||||
if (png instanceof HTMLImageElement) {
|
||||
triggerDownload(png.src, name)
|
||||
return
|
||||
}
|
||||
if (png instanceof Blob) {
|
||||
const url = URL.createObjectURL(png)
|
||||
triggerDownload(url, name)
|
||||
URL.revokeObjectURL(url)
|
||||
return
|
||||
}
|
||||
if (typeof png === 'string') {
|
||||
triggerDownload(png, name)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// swallow
|
||||
}
|
||||
|
||||
// Last Fallback: toImg
|
||||
try {
|
||||
const img: HTMLImageElement = await result.toImg()
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
triggerDownload(img.src, options?.filename ?? `wlb-report-${ts}.png`)
|
||||
triggerDownload(dataUrl, options?.filename ?? `wlb-report-${ts}.png`)
|
||||
} catch (e) {
|
||||
// As a last resort, do nothing but log
|
||||
console.error('captureAndDownloadPng: all export paths failed', e)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user