From f0af367c3424af4c5122954256666c95db8b50bc Mon Sep 17 00:00:00 2001 From: Pouzor Date: Mon, 29 Jun 2026 01:13:55 +0200 Subject: [PATCH] fix: force export background on the live react-flow element The html-to-image style-option override did not reliably beat the react-flow root's colorMode background, so white exports stayed black. Set the background colour directly on the live element for the duration of the capture (then restore it), which html-to-image reads when it clones computed styles. Refs #165 --- frontend/src/utils/__tests__/export.test.ts | 30 ++++++++++----- frontend/src/utils/export.ts | 42 +++++++++++++-------- 2 files changed, 47 insertions(+), 25 deletions(-) diff --git a/frontend/src/utils/__tests__/export.test.ts b/frontend/src/utils/__tests__/export.test.ts index f0c19ce..ed25880 100644 --- a/frontend/src/utils/__tests__/export.test.ts +++ b/frontend/src/utils/__tests__/export.test.ts @@ -68,12 +68,18 @@ describe('exportToPng', () => { expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ backgroundColor: '#ffffff' })) }) - it('overrides the react-flow root background via inline style so white is visible', async () => { + it('forces the element background to white during capture, then restores it', async () => { + el.style.backgroundColor = 'rgb(13, 17, 23)' + let bgDuringCapture = '' + mockToPng.mockImplementation(() => { + bgDuringCapture = el.style.backgroundColor + return Promise.resolve('data:image/png;base64,abc') + }) await exportToPng(el, 'standard', 'white') - expect(mockToPng).toHaveBeenCalledWith( - el, - expect.objectContaining({ style: expect.objectContaining({ backgroundColor: '#ffffff' }) }), - ) + // the live element is painted white only for the duration of the capture + expect(bgDuringCapture).toBe('rgb(255, 255, 255)') + // and is restored afterwards so the on-screen canvas is untouched + expect(el.style.backgroundColor).toBe('rgb(13, 17, 23)') }) it('attaches the download anchor to the DOM so Firefox triggers the download', async () => { @@ -117,12 +123,16 @@ describe('exportToSvg', () => { expect(mockToSvg).toHaveBeenCalledWith(el, expect.objectContaining({ backgroundColor: '#ffffff' })) }) - it('overrides the react-flow root background via inline style so white is visible', async () => { + it('forces the element background to white during capture, then restores it', async () => { + el.style.backgroundColor = 'rgb(13, 17, 23)' + let bgDuringCapture = '' + mockToSvg.mockImplementation(() => { + bgDuringCapture = el.style.backgroundColor + return Promise.resolve('data:image/svg+xml;base64,abc') + }) await exportToSvg(el, 'white') - expect(mockToSvg).toHaveBeenCalledWith( - el, - expect.objectContaining({ style: expect.objectContaining({ backgroundColor: '#ffffff' }) }), - ) + expect(bgDuringCapture).toBe('rgb(255, 255, 255)') + expect(el.style.backgroundColor).toBe('rgb(13, 17, 23)') }) it('triggers a download', async () => { diff --git a/frontend/src/utils/export.ts b/frontend/src/utils/export.ts index f2d717b..f2c1ad2 100644 --- a/frontend/src/utils/export.ts +++ b/frontend/src/utils/export.ts @@ -19,6 +19,24 @@ function backgroundColor(background: ExportBackground): string { return (EXPORT_BACKGROUND_OPTIONS.find((o) => o.value === background) ?? EXPORT_BACKGROUND_OPTIONS[0]).color } +// The `.react-flow` element paints its own opaque background (colorMode dark), +// and html-to-image's `backgroundColor` option only shows through transparent +// areas. Force the chosen colour directly on the live element for the duration +// of the capture, then restore whatever was there before. +async function withBackground( + element: HTMLElement, + color: string, + capture: () => Promise, +): Promise { + const previous = element.style.backgroundColor + element.style.backgroundColor = color + try { + return await capture() + } finally { + element.style.backgroundColor = previous + } +} + export async function exportToPng( element: HTMLElement, quality: ExportQuality = 'high', @@ -26,16 +44,12 @@ export async function exportToPng( ): Promise { const option = EXPORT_QUALITY_OPTIONS.find((o) => o.value === quality) ?? EXPORT_QUALITY_OPTIONS[1] const color = backgroundColor(background) - const dataUrl = await toPng(element, { - backgroundColor: color, - pixelRatio: option.pixelRatio, - style: { - '--xy-controls-display': 'none', - // The .react-flow root paints its own opaque background (colorMode), - // which would hide the canvas backgroundColor above — override it inline. + const dataUrl = await withBackground(element, color, () => + toPng(element, { backgroundColor: color, - } as Partial, - }) + pixelRatio: option.pixelRatio, + }), + ) triggerDownload(dataUrl, 'homelable-canvas.png') } @@ -45,13 +59,11 @@ export async function exportToSvg( background: ExportBackground = 'dark', ): Promise { const color = backgroundColor(background) - const dataUrl = await toSvg(element, { - backgroundColor: color, - style: { - '--xy-controls-display': 'none', + const dataUrl = await withBackground(element, color, () => + toSvg(element, { backgroundColor: color, - } as Partial, - }) + }), + ) triggerDownload(dataUrl, 'homelable-canvas.svg') }