[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
This commit is contained in:
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user