mirror of
https://github.com/KwiTsukasa/kt-template-online-playground.git
synced 2026-05-27 16:45:45 +08:00
fix: 使用 html2canvas 生成预览截图
This commit is contained in:
parent
8910a1bbf5
commit
526dcfb6cd
@ -121,6 +121,7 @@
|
||||
"vue-tsc": "3.0.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.16.0"
|
||||
"axios": "^1.16.0",
|
||||
"html2canvas": "^1.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,9 @@ importers:
|
||||
axios:
|
||||
specifier: ^1.16.0
|
||||
version: 1.16.0
|
||||
html2canvas:
|
||||
specifier: ^1.4.1
|
||||
version: 1.4.1
|
||||
devDependencies:
|
||||
'@babel/standalone':
|
||||
specifier: ^7.28.2
|
||||
@ -1005,6 +1008,10 @@ packages:
|
||||
balanced-match@1.0.2:
|
||||
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:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
|
||||
@ -1197,6 +1204,9 @@ packages:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
css-line-break@2.1.0:
|
||||
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
|
||||
|
||||
cssesc@3.0.0:
|
||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||
engines: {node: '>=4'}
|
||||
@ -1556,6 +1566,10 @@ packages:
|
||||
html-void-elements@3.0.0:
|
||||
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||
|
||||
html2canvas@1.4.1:
|
||||
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
ignore@5.3.2:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
@ -2267,6 +2281,9 @@ packages:
|
||||
resolution: {integrity: sha512-bX655WZI/F7EoTDw9JvQURqAXiPHi8o8+yFxPF2lWYyz1aHnmMRuXWqL6YB6GmeO0o4DIYWHLgGNi/X64T+X4Q==}
|
||||
engines: {node: '>=14.18'}
|
||||
|
||||
text-segmentation@1.0.3:
|
||||
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
|
||||
|
||||
thenify-all@1.6.0:
|
||||
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
|
||||
engines: {node: '>=0.8'}
|
||||
@ -2387,6 +2404,9 @@ packages:
|
||||
util@0.12.5:
|
||||
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:
|
||||
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
|
||||
|
||||
@ -3597,6 +3617,8 @@ snapshots:
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
base64-arraybuffer@1.0.2: {}
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
|
||||
brace-expansion@1.1.11:
|
||||
@ -3801,6 +3823,10 @@ snapshots:
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
css-line-break@2.1.0:
|
||||
dependencies:
|
||||
utrie: 1.0.2
|
||||
|
||||
cssesc@3.0.0: {}
|
||||
|
||||
csstype@3.1.3: {}
|
||||
@ -4173,6 +4199,11 @@ snapshots:
|
||||
|
||||
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@7.0.5: {}
|
||||
@ -4889,6 +4920,10 @@ snapshots:
|
||||
dependencies:
|
||||
temp-dir: 3.0.0
|
||||
|
||||
text-segmentation@1.0.3:
|
||||
dependencies:
|
||||
utrie: 1.0.2
|
||||
|
||||
thenify-all@1.6.0:
|
||||
dependencies:
|
||||
thenify: 3.3.1
|
||||
@ -5013,6 +5048,10 @@ snapshots:
|
||||
is-typed-array: 1.1.15
|
||||
which-typed-array: 1.1.18
|
||||
|
||||
utrie@1.0.2:
|
||||
dependencies:
|
||||
base64-arraybuffer: 1.0.2
|
||||
|
||||
validate-npm-package-license@3.0.4:
|
||||
dependencies:
|
||||
spdx-correct: 3.2.0
|
||||
|
||||
@ -1,16 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import html2canvas from 'html2canvas'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import {
|
||||
saveComponent,
|
||||
updateComponent,
|
||||
type ComponentPayload,
|
||||
} from './api/component'
|
||||
import {
|
||||
getComponentDictByType,
|
||||
getDictByKey,
|
||||
type DictItem,
|
||||
} from './api/dict'
|
||||
import { uploadFile } from './api/minio'
|
||||
import { getComponentDictByType, getDictByKey, type DictItem } from './api/dict'
|
||||
import { getResourceProxyEndpoint, uploadFile } from './api/minio'
|
||||
import type { ReplStore } from './store'
|
||||
|
||||
type ComponentForm = {
|
||||
@ -64,28 +61,21 @@ function getPreviewIframe() {
|
||||
return document.querySelector<HTMLIFrameElement>('.iframe-container iframe')
|
||||
}
|
||||
|
||||
function collectStyleText(doc: Document) {
|
||||
return Array.from(doc.styleSheets)
|
||||
.map((sheet) => {
|
||||
try {
|
||||
return Array.from(sheet.cssRules)
|
||||
.map((rule) => rule.cssText)
|
||||
.join('\n')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
function canvasToBlob(canvas: HTMLCanvasElement) {
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
try {
|
||||
canvas.toBlob(
|
||||
(blob) => (blob ? resolve(blob) : reject(new Error('截图生成失败'))),
|
||||
'image/png',
|
||||
)
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function loadImage(src: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image()
|
||||
image.onload = () => resolve(image)
|
||||
image.onerror = () => reject(new Error('截图生成失败'))
|
||||
image.src = src
|
||||
})
|
||||
function isCanvasSecurityError(err: unknown) {
|
||||
return err instanceof DOMException && err.name === 'SecurityError'
|
||||
}
|
||||
|
||||
async function capturePreviewImage() {
|
||||
@ -100,51 +90,43 @@ async function capturePreviewImage() {
|
||||
|
||||
const width = Math.max(iframe.clientWidth, 1)
|
||||
const height = Math.max(iframe.clientHeight, 1)
|
||||
const cloned = doc.body.cloneNode(true) as HTMLElement
|
||||
const styleText = collectStyleText(doc)
|
||||
const canvas = await html2canvas(doc.body, {
|
||||
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())
|
||||
cloned.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml')
|
||||
cloned.style.width = `${width}px`
|
||||
cloned.style.height = `${height}px`
|
||||
cloned.style.margin = '0'
|
||||
cloned.style.overflow = 'hidden'
|
||||
|
||||
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' }),
|
||||
)
|
||||
// 交给 html2canvas 处理资源加载,只在克隆文档里固定截图视口,避免内容因 body 尺寸漂移。
|
||||
clonedBody.querySelectorAll('script').forEach((item) => item.remove())
|
||||
clonedBody.style.width = `${width}px`
|
||||
clonedBody.style.height = `${height}px`
|
||||
clonedBody.style.margin = '0'
|
||||
clonedBody.style.overflow = 'hidden'
|
||||
clonedBody.style.background = '#ffffff'
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const image = await loadImage(svgUrl)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
canvas.getContext('2d')?.drawImage(image, 0, 0, width, height)
|
||||
return await canvasToBlob(canvas)
|
||||
} catch (err) {
|
||||
if (isCanvasSecurityError(err)) {
|
||||
throw new Error('截图失败:预览中仍有跨域资源污染画布')
|
||||
}
|
||||
|
||||
return await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) =>
|
||||
blob ? resolve(blob) : reject(new Error('截图生成失败')),
|
||||
'image/png',
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
URL.revokeObjectURL(svgUrl)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { post } from './request'
|
||||
import { getApiUrl, post } from './request'
|
||||
|
||||
export type MinioUploadResult = {
|
||||
url: string
|
||||
@ -7,3 +7,7 @@ export type MinioUploadResult = {
|
||||
export const uploadFile = (data: FormData) => {
|
||||
return post<MinioUploadResult>('/minio/upload', data)
|
||||
}
|
||||
|
||||
export const getResourceProxyEndpoint = () => {
|
||||
return getApiUrl('/minio/resource-proxy')
|
||||
}
|
||||
|
||||
@ -11,6 +11,14 @@ const request = axios.create({
|
||||
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(
|
||||
(response) => {
|
||||
const data = response.data as ApiResponse<any>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user