Bläddra i källkod

[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
Miguel Serrano 2 månader sedan
förälder
incheckning
35681dd3b2

+ 69 - 8
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 (
-    <img
-      src={`/project/${projectId}/blob/${file.hash}`}
-      onLoad={onLoad}
-      onError={onError}
-      alt={file.name}
-    />
-  )
+
+  const urlPath = `/project/${projectId}/blob/${file.hash}`
+  const extension = file.name.split('.')?.pop()?.toLowerCase()
+
+  if (extension === 'svg') {
+    return (
+      <SVGRenderer
+        url={urlPath}
+        onLoad={onLoad}
+        onError={onError}
+        alt={file.name}
+      />
+    )
+  } else {
+    return (
+      <img src={urlPath} onLoad={onLoad} onError={onError} alt={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<string | null>(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 <img src={objectUrl} onLoad={onLoad} onError={onError} alt={alt} />
 }

+ 1 - 1
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)

+ 58 - 2
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 =
+  '<svg xmlns="http://www.w3.org/2000/svg"><circle r="10"/></svg>'
 
 describe('<FileViewImage />', 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(
       <FileViewImage file={imageFile} onError={() => {}} 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(
+      <FileViewImage file={svgFile} onError={() => {}} 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(
+      <FileViewImage file={svgFile} onLoad={() => {}} onError={onError} />
+    )
+
+    await waitFor(() => {
+      sinon.assert.calledOnce(onError)
+    })
+
+    expect(screen.queryByRole('img')).to.not.exist
+  })
 })