From e92267f2b765d7c587dff24a4025a9b71f646d40 Mon Sep 17 00:00:00 2001 From: zhaokun Date: Wed, 22 Oct 2025 15:57:49 +0800 Subject: [PATCH 1/4] [Bug] Export as image only exports a portion: capture full content in side-by-side and grid by expanding nested scrollables and using width/height for html-to-image --- src/renderer/src/utils/image.ts | 71 ++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/utils/image.ts b/src/renderer/src/utils/image.ts index a42f372f3c..e98b48918d 100644 --- a/src/renderer/src/utils/image.ts +++ b/src/renderer/src/utils/image.ts @@ -71,6 +71,10 @@ export const captureScrollable = async (elRef: React.RefObject> + const modifiedElements: Array<{ node: HTMLElement; style: SavedStyle }> = [] + // Hide scrollbars during capture el.classList.add('hide-scrollbar') @@ -80,6 +84,49 @@ export const captureScrollable = async (elRef: React.RefObject { + const all = Array.from(el.querySelectorAll('*')) + 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 }) + } + + // Expand vertical scroll regions + if (cs.overflowY === 'auto' || cs.overflowY === 'scroll' || cs.overflowY === 'hidden') { + save(['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']) + node.style.overflowX = 'visible' + node.style.overflow = 'visible' + // Ensure full width for horizontally-scrolling containers + const sw = Math.max(node.scrollWidth, node.clientWidth) + if (sw > 0) node.style.width = `${sw}px` + } + + // Special case: message content containers in horizontal layout + if (node.classList.contains('message-content-container')) { + save(['maxHeight', 'overflow', 'overflowY', 'height']) + node.style.maxHeight = 'none' + node.style.overflow = 'visible' + node.style.overflowY = 'visible' + node.style.height = 'auto' + } + } + } + + expandNestedScrollables() + // calculate the size of the element const totalWidth = el.scrollWidth const totalHeight = el.scrollHeight @@ -93,6 +140,16 @@ export const captureScrollable = async (elRef: React.RefObject { el.scrollTop = originalScrollTop @@ -109,8 +166,8 @@ export const captureScrollable = async (elRef: React.RefObject Date: Wed, 22 Oct 2025 16:30:30 +0800 Subject: [PATCH 2/4] 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 From 234236716b1fff04ef6c3033f4f32a6f74c51f54 Mon Sep 17 00:00:00 2001 From: zhaokun Date: Thu, 23 Oct 2025 09:49:18 +0800 Subject: [PATCH 3/4] style(renderer): format image capture utility code Improve code readability by reformatting type definitions and logical expressions --- src/renderer/src/utils/image.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/utils/image.ts b/src/renderer/src/utils/image.ts index 53669bd29f..35385812e9 100644 --- a/src/renderer/src/utils/image.ts +++ b/src/renderer/src/utils/image.ts @@ -73,7 +73,10 @@ export const captureScrollable = async (elRef: React.RefObject + Pick< + CSSStyleDeclaration, + 'overflow' | 'overflowX' | 'overflowY' | 'height' | 'maxHeight' | 'width' | 'minWidth' + > > const savedStyles = new Map() const modifiedNodes: HTMLElement[] = [] @@ -135,10 +138,7 @@ export const captureScrollable = async (elRef: React.RefObject node.clientHeight - ) { + 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' @@ -190,7 +190,10 @@ export const captureScrollable = async (elRef: React.RefObject((resolve, reject) => { htmlToImage From bee494b28d2c41d85c4f6aa3d6e6c64e7d96135d Mon Sep 17 00:00:00 2001 From: zhaokun Date: Thu, 23 Oct 2025 10:38:55 +0800 Subject: [PATCH 4/4] fix(renderer): remove obsolete ts-expect-error from image utility --- src/renderer/src/utils/image.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/renderer/src/utils/image.ts b/src/renderer/src/utils/image.ts index 35385812e9..ed655b33a9 100644 --- a/src/renderer/src/utils/image.ts +++ b/src/renderer/src/utils/image.ts @@ -89,8 +89,6 @@ export const captureScrollable = async (elRef: React.RefObject