fix(capture): restore AI screenshot framing

This commit is contained in:
digua committed 2026-07-16 00:34:16 +08:00
1 parent 1b940143fd
commit 3c8b83d571
8 files changed
+173 -185

No files matched your search

+1 -1
View File
@@ -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",
+7 -22
View File
@@ -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>
+3
View File
@@ -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,
}
+28 -27
View File
@@ -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
}
}
+32 -1
View File
@@ -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 () => {
+39
View File
@@ -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
View File
@@ -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)
}
}