fix: 裁剪预览截图内容区

This commit is contained in:
sunlei 2026-05-16 11:55:01 +08:00
parent 526dcfb6cd
commit 99236ba861

View File

@ -32,6 +32,7 @@ const componentTypeList = ref<DictItem[]>([])
const loading = ref(false) const loading = ref(false)
const message = ref('') const message = ref('')
const messageType = ref<'info' | 'success' | 'error'>('info') const messageType = ref<'info' | 'success' | 'error'>('info')
const screenshotPadding = 6
const isEdit = computed(() => !!form.id) const isEdit = computed(() => !!form.id)
const canSave = computed( const canSave = computed(
@ -78,6 +79,169 @@ function isCanvasSecurityError(err: unknown) {
return err instanceof DOMException && err.name === 'SecurityError' return err instanceof DOMException && err.name === 'SecurityError'
} }
type ScreenshotBounds = {
left: number
top: number
right: number
bottom: number
}
type ScreenshotArea = {
x: number
y: number
width: number
height: number
windowWidth: number
windowHeight: number
}
function mergeBounds(
bounds: ScreenshotBounds | undefined,
rect: Pick<DOMRect, 'left' | 'top' | 'right' | 'bottom' | 'width' | 'height'>,
) {
if (rect.width <= 0 || rect.height <= 0) {
return bounds
}
if (!bounds) {
return {
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
}
}
return {
left: Math.min(bounds.left, rect.left),
top: Math.min(bounds.top, rect.top),
right: Math.max(bounds.right, rect.right),
bottom: Math.max(bounds.bottom, rect.bottom),
}
}
function isTransparentColor(value: string) {
return (
!value ||
value === 'transparent' ||
value === 'rgba(0, 0, 0, 0)' ||
value === 'rgb(0 0 0 / 0)'
)
}
function hasPaintedBox(style: CSSStyleDeclaration) {
const borderWidth =
Number.parseFloat(style.borderTopWidth) +
Number.parseFloat(style.borderRightWidth) +
Number.parseFloat(style.borderBottomWidth) +
Number.parseFloat(style.borderLeftWidth)
return (
style.backgroundImage !== 'none' ||
!isTransparentColor(style.backgroundColor) ||
borderWidth > 0 ||
style.boxShadow !== 'none' ||
style.outlineStyle !== 'none'
)
}
function isVisibleElement(element: Element, win: Window) {
const style = win.getComputedStyle(element)
return (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
Number(style.opacity) !== 0
)
}
function isRenderableElement(element: Element, win: Window) {
const tagName = element.tagName.toLowerCase()
return (
[
'button',
'canvas',
'img',
'input',
'select',
'svg',
'table',
'textarea',
'video',
].includes(tagName) || hasPaintedBox(win.getComputedStyle(element))
)
}
function getContentBounds(root: HTMLElement, doc: Document) {
const win = doc.defaultView || window
let bounds: ScreenshotBounds | undefined
root.querySelectorAll('*').forEach((element) => {
if (!isVisibleElement(element, win) || !isRenderableElement(element, win)) {
return
}
bounds = mergeBounds(bounds, element.getBoundingClientRect())
})
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT)
let node = walker.nextNode()
while (node) {
const text = node.textContent?.trim()
if (text) {
const range = doc.createRange()
range.selectNodeContents(node)
Array.from(range.getClientRects()).forEach((rect) => {
bounds = mergeBounds(bounds, rect)
})
range.detach()
}
node = walker.nextNode()
}
return bounds || mergeBounds(undefined, root.getBoundingClientRect())
}
function getPreviewContentArea(doc: Document, iframe: HTMLIFrameElement) {
const root = (doc.querySelector('#app') as HTMLElement | null) || doc.body
const bounds = getContentBounds(root, doc)
if (!bounds) {
throw new Error('预览内容为空,无法生成截图')
}
const win = doc.defaultView
const scrollX = win?.scrollX || 0
const scrollY = win?.scrollY || 0
const x = Math.max(0, Math.floor(bounds.left + scrollX - screenshotPadding))
const y = Math.max(0, Math.floor(bounds.top + scrollY - screenshotPadding))
const right = Math.ceil(bounds.right + scrollX + screenshotPadding)
const bottom = Math.ceil(bounds.bottom + scrollY + screenshotPadding)
const width = Math.max(1, right - x)
const height = Math.max(1, bottom - y)
return {
x,
y,
width,
height,
windowWidth: Math.max(
iframe.clientWidth,
doc.documentElement.scrollWidth,
right,
),
windowHeight: Math.max(
iframe.clientHeight,
doc.documentElement.scrollHeight,
bottom,
),
} satisfies ScreenshotArea
}
async function capturePreviewImage() { async function capturePreviewImage() {
await nextFrame() await nextFrame()
@ -88,33 +252,28 @@ async function capturePreviewImage() {
throw new Error('未找到预览区域,无法生成截图') throw new Error('未找到预览区域,无法生成截图')
} }
const width = Math.max(iframe.clientWidth, 1) const area = getPreviewContentArea(doc, iframe)
const height = Math.max(iframe.clientHeight, 1)
const canvas = await html2canvas(doc.body, { const canvas = await html2canvas(doc.body, {
allowTaint: false, allowTaint: false,
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
height, height: area.height,
imageTimeout: 15000, imageTimeout: 15000,
logging: false, logging: false,
proxy: getResourceProxyEndpoint(), proxy: getResourceProxyEndpoint(),
scale: Math.min(window.devicePixelRatio || 1, 2), scale: Math.min(window.devicePixelRatio || 1, 2),
useCORS: false, useCORS: false,
width, width: area.width,
windowHeight: height, windowHeight: area.windowHeight,
windowWidth: width, windowWidth: area.windowWidth,
x: 0, x: area.x,
y: 0, y: area.y,
scrollX: 0, scrollX: 0,
scrollY: 0, scrollY: 0,
onclone: (clonedDoc) => { onclone: (clonedDoc) => {
const clonedBody = clonedDoc.body const clonedBody = clonedDoc.body
// html2canvas body // html2canvas
clonedBody.querySelectorAll('script').forEach((item) => item.remove()) 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' clonedBody.style.background = '#ffffff'
}, },
}) })