fix(renderer): Optimize full-content export for complex layouts

This commit addresses performance and reliability issues with full-content export
in side-by-side and grid layouts by implementing targeted improvements:

- Replace canvasWidth/canvasHeight with width/height in html-to-image options
  for more accurate style-based sizing of exported content

- Optimize scrollable container expansion by only modifying actually clipped elements
  instead of all scrollables, reducing unnecessary DOM manipulations

- Improve style management using Map for aggregated inline style storage
  and reverse-order restoration to maintain proper CSS cascading behavior

- Prevent layout breakage in horizontal scroll regions by using minWidth instead of width
  to preserve content flow during export process

- Enhance rendering stability with requestAnimationFrame to wait for layout settlement
  and pixelRatio capping to avoid exceeding browser canvas limitations

These changes improve export quality, reduce performance overhead, and prevent
common issues like clipped content, layout distortion, and canvas size errors
in complex multi-column layouts.
This commit is contained in:
zhaokun 2025-10-22 16:30:30 +08:00
parent e92267f2b7
commit 622901178c

View File

@ -72,8 +72,26 @@ export const captureScrollable = async (elRef: React.RefObject<HTMLElement | nul
const originalScrollTop = el.scrollTop
// Track nested elements we temporarily modify
type SavedStyle = Partial<Pick<CSSStyleDeclaration, 'overflow' | 'overflowX' | 'overflowY' | 'height' | 'maxHeight' | 'width'>>
const modifiedElements: Array<{ node: HTMLElement; style: SavedStyle }> = []
type SavedStyle = Partial<
Pick<CSSStyleDeclaration, 'overflow' | 'overflowX' | 'overflowY' | 'height' | 'maxHeight' | 'width' | 'minWidth'>
>
const savedStyles = new Map<HTMLElement, SavedStyle>()
const modifiedNodes: HTMLElement[] = []
const save = (node: HTMLElement, fields: Array<keyof SavedStyle>) => {
let saved = savedStyles.get(node)
if (!saved) {
saved = {}
savedStyles.set(node, saved)
modifiedNodes.push(node)
}
for (const f of fields) {
if (saved[f] === undefined) {
// Record inline style only once
// @ts-expect-error index signature
saved[f] = node.style[f]
}
}
}
// Hide scrollbars during capture
el.classList.add('hide-scrollbar')
@ -89,34 +107,39 @@ export const captureScrollable = async (elRef: React.RefObject<HTMLElement | nul
const all = Array.from(el.querySelectorAll<HTMLElement>('*'))
for (const node of all) {
const cs = getComputedStyle(node)
const save = (fields: Array<keyof SavedStyle>) => {
const saved: SavedStyle = {}
for (const f of fields) saved[f] = (node.style as any)[f]
modifiedElements.push({ node, style: saved })
}
if (cs.display === 'none') continue
// Expand vertical scroll regions
if (cs.overflowY === 'auto' || cs.overflowY === 'scroll' || cs.overflowY === 'hidden') {
save(['overflow', 'overflowY', 'height', 'maxHeight'])
// Expand vertical scroll regions only if content is clipped
const needsV =
(cs.overflowY === 'auto' || cs.overflowY === 'scroll' || cs.overflowY === 'hidden') &&
node.scrollHeight > node.clientHeight
if (needsV) {
save(node, ['overflow', 'overflowY', 'height', 'maxHeight'])
node.style.overflowY = 'visible'
node.style.overflow = 'visible'
node.style.maxHeight = 'none'
node.style.height = 'auto'
}
// Expand horizontal scroll regions
if (cs.overflowX === 'auto' || cs.overflowX === 'scroll' || cs.overflowX === 'hidden') {
save(['overflow', 'overflowX', 'width'])
// Expand horizontal scroll regions only if content is clipped
const needsH =
(cs.overflowX === 'auto' || cs.overflowX === 'scroll' || cs.overflowX === 'hidden') &&
node.scrollWidth > node.clientWidth
if (needsH) {
save(node, ['overflow', 'overflowX', 'minWidth'])
node.style.overflowX = 'visible'
node.style.overflow = 'visible'
// Ensure full width for horizontally-scrolling containers
// Ensure full width for horizontally-scrolling containers using minWidth to avoid layout breakage
const sw = Math.max(node.scrollWidth, node.clientWidth)
if (sw > 0) node.style.width = `${sw}px`
if (sw > 0) node.style.minWidth = `${sw}px`
}
// Special case: message content containers in horizontal layout
if (node.classList.contains('message-content-container')) {
save(['maxHeight', 'overflow', 'overflowY', 'height'])
if (
node.classList.contains('message-content-container') &&
node.scrollHeight > node.clientHeight
) {
save(node, ['maxHeight', 'overflow', 'overflowY', 'height'])
node.style.maxHeight = 'none'
node.style.overflow = 'visible'
node.style.overflowY = 'visible'
@ -127,7 +150,10 @@ export const captureScrollable = async (elRef: React.RefObject<HTMLElement | nul
expandNestedScrollables()
// calculate the size of the element
// Wait one frame for layout to settle after style changes
await new Promise((r) => requestAnimationFrame(() => r(null)))
// calculate the size of the element after expansion
const totalWidth = el.scrollWidth
const totalHeight = el.scrollHeight
@ -141,13 +167,17 @@ export const captureScrollable = async (elRef: React.RefObject<HTMLElement | nul
el.style.position = originalStyle.position
// restore nested modified elements
for (const { node, style } of modifiedElements.reverse()) {
for (let i = modifiedNodes.length - 1; i >= 0; i--) {
const node = modifiedNodes[i]
const style = savedStyles.get(node)
if (!style) continue
if (style.overflow !== undefined) node.style.overflow = style.overflow
if (style.overflowX !== undefined) node.style.overflowX = style.overflowX
if (style.overflowY !== undefined) node.style.overflowY = style.overflowY
if (style.height !== undefined) node.style.height = style.height
if (style.maxHeight !== undefined) node.style.maxHeight = style.maxHeight
if (style.width !== undefined) node.style.width = style.width
if (style.minWidth !== undefined) node.style.minWidth = style.minWidth
}
// restore the original scroll position
@ -158,19 +188,26 @@ export const captureScrollable = async (elRef: React.RefObject<HTMLElement | nul
window.toast.error(i18n.t('message.error.dimension_too_large'))
return Promise.reject()
}
// Cap pixelRatio to avoid exceeding browser canvas limits
const maxLogical = Math.max(totalWidth, totalHeight)
const maxAllowedPR = Math.max(0.1, Math.min(window.devicePixelRatio, MAX_ALLOWED_DIMENSION / Math.max(1, maxLogical)))
const canvas = await new Promise<HTMLCanvasElement>((resolve, reject) => {
htmlToImage
.toCanvas(el, {
backgroundColor: getComputedStyle(el).getPropertyValue('--color-background'),
cacheBust: true,
pixelRatio: window.devicePixelRatio,
pixelRatio: maxAllowedPR,
skipAutoScale: true,
width: el.scrollWidth,
height: el.scrollHeight,
width: totalWidth,
height: totalHeight,
style: {
backgroundColor: getComputedStyle(el).backgroundColor,
color: getComputedStyle(el).color
color: getComputedStyle(el).color,
width: `${totalWidth}px`,
height: `${totalHeight}px`,
overflow: 'visible',
display: 'block'
}
})
.then((canvas) => resolve(canvas))
@ -184,13 +221,17 @@ export const captureScrollable = async (elRef: React.RefObject<HTMLElement | nul
el.style.position = originalStyle.position
// Restore nested modified elements
for (const { node, style } of modifiedElements.reverse()) {
for (let i = modifiedNodes.length - 1; i >= 0; i--) {
const node = modifiedNodes[i]
const style = savedStyles.get(node)
if (!style) continue
if (style.overflow !== undefined) node.style.overflow = style.overflow
if (style.overflowX !== undefined) node.style.overflowX = style.overflowX
if (style.overflowY !== undefined) node.style.overflowY = style.overflowY
if (style.height !== undefined) node.style.height = style.height
if (style.maxHeight !== undefined) node.style.maxHeight = style.maxHeight
if (style.width !== undefined) node.style.width = style.width
if (style.minWidth !== undefined) node.style.minWidth = style.minWidth
}
const imageData = canvas