fix: 使用 html2canvas 生成预览截图

This commit is contained in:
sunlei 2026-05-16 11:42:57 +08:00
parent 8910a1bbf5
commit 526dcfb6cd
5 changed files with 103 additions and 69 deletions

View File

@ -121,6 +121,7 @@
"vue-tsc": "3.0.8" "vue-tsc": "3.0.8"
}, },
"dependencies": { "dependencies": {
"axios": "^1.16.0" "axios": "^1.16.0",
"html2canvas": "^1.4.1"
} }
} }

View File

@ -11,6 +11,9 @@ importers:
axios: axios:
specifier: ^1.16.0 specifier: ^1.16.0
version: 1.16.0 version: 1.16.0
html2canvas:
specifier: ^1.4.1
version: 1.4.1
devDependencies: devDependencies:
'@babel/standalone': '@babel/standalone':
specifier: ^7.28.2 specifier: ^7.28.2
@ -1005,6 +1008,10 @@ packages:
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
boolbase@1.0.0: boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
@ -1197,6 +1204,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
css-line-break@2.1.0:
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
cssesc@3.0.0: cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -1556,6 +1566,10 @@ packages:
html-void-elements@3.0.0: html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
html2canvas@1.4.1:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'}
ignore@5.3.2: ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
@ -2267,6 +2281,9 @@ packages:
resolution: {integrity: sha512-bX655WZI/F7EoTDw9JvQURqAXiPHi8o8+yFxPF2lWYyz1aHnmMRuXWqL6YB6GmeO0o4DIYWHLgGNi/X64T+X4Q==} resolution: {integrity: sha512-bX655WZI/F7EoTDw9JvQURqAXiPHi8o8+yFxPF2lWYyz1aHnmMRuXWqL6YB6GmeO0o4DIYWHLgGNi/X64T+X4Q==}
engines: {node: '>=14.18'} engines: {node: '>=14.18'}
text-segmentation@1.0.3:
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
thenify-all@1.6.0: thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
@ -2387,6 +2404,9 @@ packages:
util@0.12.5: util@0.12.5:
resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==}
utrie@1.0.2:
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
validate-npm-package-license@3.0.4: validate-npm-package-license@3.0.4:
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
@ -3597,6 +3617,8 @@ snapshots:
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
base64-arraybuffer@1.0.2: {}
boolbase@1.0.0: {} boolbase@1.0.0: {}
brace-expansion@1.1.11: brace-expansion@1.1.11:
@ -3801,6 +3823,10 @@ snapshots:
shebang-command: 2.0.0 shebang-command: 2.0.0
which: 2.0.2 which: 2.0.2
css-line-break@2.1.0:
dependencies:
utrie: 1.0.2
cssesc@3.0.0: {} cssesc@3.0.0: {}
csstype@3.1.3: {} csstype@3.1.3: {}
@ -4173,6 +4199,11 @@ snapshots:
html-void-elements@3.0.0: {} html-void-elements@3.0.0: {}
html2canvas@1.4.1:
dependencies:
css-line-break: 2.1.0
text-segmentation: 1.0.3
ignore@5.3.2: {} ignore@5.3.2: {}
ignore@7.0.5: {} ignore@7.0.5: {}
@ -4889,6 +4920,10 @@ snapshots:
dependencies: dependencies:
temp-dir: 3.0.0 temp-dir: 3.0.0
text-segmentation@1.0.3:
dependencies:
utrie: 1.0.2
thenify-all@1.6.0: thenify-all@1.6.0:
dependencies: dependencies:
thenify: 3.3.1 thenify: 3.3.1
@ -5013,6 +5048,10 @@ snapshots:
is-typed-array: 1.1.15 is-typed-array: 1.1.15
which-typed-array: 1.1.18 which-typed-array: 1.1.18
utrie@1.0.2:
dependencies:
base64-arraybuffer: 1.0.2
validate-npm-package-license@3.0.4: validate-npm-package-license@3.0.4:
dependencies: dependencies:
spdx-correct: 3.2.0 spdx-correct: 3.2.0

View File

@ -1,16 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import html2canvas from 'html2canvas'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { import {
saveComponent, saveComponent,
updateComponent, updateComponent,
type ComponentPayload, type ComponentPayload,
} from './api/component' } from './api/component'
import { import { getComponentDictByType, getDictByKey, type DictItem } from './api/dict'
getComponentDictByType, import { getResourceProxyEndpoint, uploadFile } from './api/minio'
getDictByKey,
type DictItem,
} from './api/dict'
import { uploadFile } from './api/minio'
import type { ReplStore } from './store' import type { ReplStore } from './store'
type ComponentForm = { type ComponentForm = {
@ -64,28 +61,21 @@ function getPreviewIframe() {
return document.querySelector<HTMLIFrameElement>('.iframe-container iframe') return document.querySelector<HTMLIFrameElement>('.iframe-container iframe')
} }
function collectStyleText(doc: Document) { function canvasToBlob(canvas: HTMLCanvasElement) {
return Array.from(doc.styleSheets) return new Promise<Blob>((resolve, reject) => {
.map((sheet) => {
try { try {
return Array.from(sheet.cssRules) canvas.toBlob(
.map((rule) => rule.cssText) (blob) => (blob ? resolve(blob) : reject(new Error('截图生成失败'))),
.join('\n') 'image/png',
} catch { )
return '' } catch (err) {
reject(err)
} }
}) })
.filter(Boolean)
.join('\n')
} }
function loadImage(src: string) { function isCanvasSecurityError(err: unknown) {
return new Promise<HTMLImageElement>((resolve, reject) => { return err instanceof DOMException && err.name === 'SecurityError'
const image = new Image()
image.onload = () => resolve(image)
image.onerror = () => reject(new Error('截图生成失败'))
image.src = src
})
} }
async function capturePreviewImage() { async function capturePreviewImage() {
@ -100,51 +90,43 @@ async function capturePreviewImage() {
const width = Math.max(iframe.clientWidth, 1) const width = Math.max(iframe.clientWidth, 1)
const height = Math.max(iframe.clientHeight, 1) const height = Math.max(iframe.clientHeight, 1)
const cloned = doc.body.cloneNode(true) as HTMLElement const canvas = await html2canvas(doc.body, {
const styleText = collectStyleText(doc) allowTaint: false,
backgroundColor: '#ffffff',
height,
imageTimeout: 15000,
logging: false,
proxy: getResourceProxyEndpoint(),
scale: Math.min(window.devicePixelRatio || 1, 2),
useCORS: false,
width,
windowHeight: height,
windowWidth: width,
x: 0,
y: 0,
scrollX: 0,
scrollY: 0,
onclone: (clonedDoc) => {
const clonedBody = clonedDoc.body
cloned.querySelectorAll('script').forEach((item) => item.remove()) // html2canvas body
cloned.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml') clonedBody.querySelectorAll('script').forEach((item) => item.remove())
cloned.style.width = `${width}px` clonedBody.style.width = `${width}px`
cloned.style.height = `${height}px` clonedBody.style.height = `${height}px`
cloned.style.margin = '0' clonedBody.style.margin = '0'
cloned.style.overflow = 'hidden' clonedBody.style.overflow = 'hidden'
clonedBody.style.background = '#ffffff'
const serializer = new XMLSerializer() },
const xhtml = Array.from(cloned.childNodes) })
.map((node) => serializer.serializeToString(node))
.join('')
const safeStyleText = styleText.replace(/\]\]>/g, ']]]]><![CDATA[>')
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
<foreignObject width="100%" height="100%">
<div xmlns="http://www.w3.org/1999/xhtml" style="width:${width}px;height:${height}px;overflow:hidden;background:#fff;">
<style><![CDATA[${safeStyleText}]]></style>
${xhtml}
</div>
</foreignObject>
</svg>
`
const svgUrl = URL.createObjectURL(
new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }),
)
try { try {
const image = await loadImage(svgUrl) return await canvasToBlob(canvas)
const canvas = document.createElement('canvas') } catch (err) {
canvas.width = width if (isCanvasSecurityError(err)) {
canvas.height = height throw new Error('截图失败:预览中仍有跨域资源污染画布')
canvas.getContext('2d')?.drawImage(image, 0, 0, width, height) }
return await new Promise<Blob>((resolve, reject) => { throw err
canvas.toBlob(
(blob) =>
blob ? resolve(blob) : reject(new Error('截图生成失败')),
'image/png',
)
})
} finally {
URL.revokeObjectURL(svgUrl)
} }
} }

View File

@ -1,4 +1,4 @@
import { post } from './request' import { getApiUrl, post } from './request'
export type MinioUploadResult = { export type MinioUploadResult = {
url: string url: string
@ -7,3 +7,7 @@ export type MinioUploadResult = {
export const uploadFile = (data: FormData) => { export const uploadFile = (data: FormData) => {
return post<MinioUploadResult>('/minio/upload', data) return post<MinioUploadResult>('/minio/upload', data)
} }
export const getResourceProxyEndpoint = () => {
return getApiUrl('/minio/resource-proxy')
}

View File

@ -11,6 +11,14 @@ const request = axios.create({
timeout: 1000 * 30, timeout: 1000 * 30,
}) })
export function getApiUrl(url: string) {
const baseURL = import.meta.env.VITE_APP_API_BASE || '/api'
const normalizedBase = baseURL.replace(/\/+$/, '')
const normalizedUrl = url.startsWith('/') ? url : `/${url}`
return `${normalizedBase}${normalizedUrl}`
}
request.interceptors.response.use( request.interceptors.response.use(
(response) => { (response) => {
const data = response.data as ApiResponse<any> const data = response.data as ApiResponse<any>