From 35681dd3b2840b7c47389dc4ac060a7bfb9cb24a Mon Sep 17 00:00:00 2001 From: Miguel Serrano Date: Wed, 20 May 2026 10:29:29 +0200 Subject: [PATCH] [web] Add SVG support to file-view panel (#32155) * [web] Add SVG support to file-view panel Adds support by reading the content of the downloaded SVG, then creating a blob and rendering it as native HTML. GitOrigin-RevId: e80c491a10db6f5757c568430e17d9cbb613c5b4 --- .../file-view/components/file-view-image.tsx | 77 +++++++++++++++++-- .../file-view/components/file-view.tsx | 2 +- .../components/file-view-image.test.tsx | 60 ++++++++++++++- 3 files changed, 128 insertions(+), 11 deletions(-) diff --git a/services/web/frontend/js/features/file-view/components/file-view-image.tsx b/services/web/frontend/js/features/file-view/components/file-view-image.tsx index f15640e00e..5e43502503 100644 --- a/services/web/frontend/js/features/file-view/components/file-view-image.tsx +++ b/services/web/frontend/js/features/file-view/components/file-view-image.tsx @@ -1,5 +1,8 @@ +import { useState, useEffect } from 'react' import { useProjectContext } from '../../../shared/context/project-context' +import { debugConsole } from '@/utils/debugging' import { BinaryFile } from '@/features/file-view/types/binary-file' +import useAbortController from '@/shared/hooks/use-abort-controller' export default function FileViewImage({ file, @@ -11,12 +14,70 @@ export default function FileViewImage({ onError: () => void }) { const { projectId } = useProjectContext() - return ( - {file.name} - ) + + const urlPath = `/project/${projectId}/blob/${file.hash}` + const extension = file.name.split('.')?.pop()?.toLowerCase() + + if (extension === 'svg') { + return ( + + ) + } else { + return ( + {file.name} + ) + } +} + +type SVGRendererProps = { + url: string + alt: string + onLoad: () => void + onError: () => void +} + +function SVGRenderer({ url, alt, onLoad, onError }: SVGRendererProps) { + const { signal } = useAbortController() + const [objectUrl, setObjectUrl] = useState(null) + + useEffect(() => { + let blobUrl: string | null = null + setObjectUrl(null) + fetch(url, { signal }) + .then(res => { + if (!res.ok) { + throw new Error(`Error fetching SVG: ${res.statusText}`) + } + return res.arrayBuffer() + }) + .then(buffer => { + const blob = new Blob([buffer], { type: 'image/svg+xml' }) + blobUrl = URL.createObjectURL(blob) + setObjectUrl(blobUrl) + }) + .catch(err => { + if (signal.aborted) return + debugConsole.error('Unable to render SVG', err) + onError() + }) + + return () => { + if (blobUrl) { + // URL.createObjectURL() allocates memory that is not garbage-collected automatically, + // we're explicitly releasing it on effect cleanup. + URL.revokeObjectURL(blobUrl) + } + } + }, [url, onError, signal]) + + if (!objectUrl) { + return null + } + + return {alt} } diff --git a/services/web/frontend/js/features/file-view/components/file-view.tsx b/services/web/frontend/js/features/file-view/components/file-view.tsx index 172efd773c..0a40178a87 100644 --- a/services/web/frontend/js/features/file-view/components/file-view.tsx +++ b/services/web/frontend/js/features/file-view/components/file-view.tsx @@ -9,7 +9,7 @@ import LoadingSpinner from '@/shared/components/loading-spinner' import getMeta from '@/utils/meta' import { BinaryFile } from '../types/binary-file' -const imageExtensions = ['png', 'jpg', 'jpeg', 'gif'] +const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'svg'] export default function FileView({ file }: { file: BinaryFile }) { const [contentLoading, setContentLoading] = useState(true) diff --git a/services/web/test/frontend/features/file-view/components/file-view-image.test.tsx b/services/web/test/frontend/features/file-view/components/file-view-image.test.tsx index 7a2486a0b2..90ecd081ae 100644 --- a/services/web/test/frontend/features/file-view/components/file-view-image.test.tsx +++ b/services/web/test/frontend/features/file-view/components/file-view-image.test.tsx @@ -1,14 +1,70 @@ -import { screen } from '@testing-library/react' +import { expect } from 'chai' +import { screen, waitFor } from '@testing-library/react' +import sinon from 'sinon' +import fetchMock from 'fetch-mock' import { renderWithEditorContext } from '../../../helpers/render-with-context' import FileViewImage from '../../../../../frontend/js/features/file-view/components/file-view-image' import { imageFile } from '../util/files' +import type { BinaryFile } from '@/features/file-view/types/binary-file' + +const svgFile: BinaryFile<'project_file'> = { + ...imageFile, + name: 'diagram.svg', +} + +const svgContent = + '' describe('', function () { - it('renders an image', function () { + beforeEach(function () { + URL.createObjectURL = sinon.stub().returns('blob:fake-url') + URL.revokeObjectURL = sinon.stub() + }) + + afterEach(function () { + fetchMock.removeRoutes().clearHistory() + }) + + it('renders a non-SVG img', function () { renderWithEditorContext( {}} onLoad={() => {}} /> ) screen.getByRole('img') }) + + it('fetches and renders SVG in an img tag via blob URL', async function () { + fetchMock.get('express:/project/:project_id/blob/:hash', svgContent) + + renderWithEditorContext( + {}} onLoad={() => {}} /> + ) + + await waitFor(() => { + const img = screen.getByRole('img') + expect(img.getAttribute('src')).to.equal('blob:fake-url') + }) + const createObjectURLStub = URL.createObjectURL as sinon.SinonStub + expect(createObjectURLStub.calledOnce).to.be.true + const blob = createObjectURLStub.firstCall.args[0] + expect(blob).to.be.instanceOf(Blob) + expect(blob.type).to.equal('image/svg+xml') + }) + + it('calls onError when SVG fetch fails', async function () { + fetchMock.get('express:/project/:project_id/blob/:hash', { + throws: new Error('Network error'), + }) + const onError = sinon.stub() + + renderWithEditorContext( + {}} onError={onError} /> + ) + + await waitFor(() => { + sinon.assert.calledOnce(onError) + }) + + expect(screen.queryByRole('img')).to.not.exist + }) })