From 622901178c81a4f00abe8387f230fede30013e8c Mon Sep 17 00:00:00 2001 From: zhaokun Date: Wed, 22 Oct 2025 16:30:30 +0800 Subject: [PATCH] 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. --- src/renderer/src/utils/image.ts | 89 ++++++++++++++++++++++++--------- 1 file changed, 65 insertions(+), 24 deletions(-) diff --git a/src/renderer/src/utils/image.ts b/src/renderer/src/utils/image.ts index e98b48918d..53669bd29f 100644 --- a/src/renderer/src/utils/image.ts +++ b/src/renderer/src/utils/image.ts @@ -72,8 +72,26 @@ export const captureScrollable = async (elRef: React.RefObject> - const modifiedElements: Array<{ node: HTMLElement; style: SavedStyle }> = [] + type SavedStyle = Partial< + Pick + > + const savedStyles = new Map() + const modifiedNodes: HTMLElement[] = [] + const save = (node: HTMLElement, fields: Array) => { + 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('*')) for (const node of all) { const cs = getComputedStyle(node) - const save = (fields: Array) => { - 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 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= 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((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= 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