mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-29 05:51:26 +08:00
refactor(SvgPreview,Markdown): make svg size adaptive (#9232)
* refactor(Svg): make svg preview scalable
* feat: make svg in markdown scalable
* refactor: add measureElementSize
* refactor: improve rehypeScalableSvg, add MarkdownSvgRenderer
* fix: svg namespace
* perf: improve namespace correction
* refactor: rename makeSvgScalable to makeSvgSizeAdaptive
* test: fix tests for renderSvgInShadowHost
* refactor: improve MarkdownSvgRenderer re-render
* feat: sanitize svg before rendering
* feat: make MarkdownSvgRenderer clickable
* test: fix
* Revert "feat: make MarkdownSvgRenderer clickable"
This reverts commit 73af8fbb8c.
* refactor: use context menu in MarkdownSvgRenderer
* refactor: remove preserveAspectRatio from svg
This commit is contained in:
parent
62a6a0a8be
commit
72d0fea3a1
@ -179,6 +179,7 @@
|
||||
"dexie-react-hooks": "^1.1.7",
|
||||
"diff": "^7.0.0",
|
||||
"docx": "^9.0.2",
|
||||
"dompurify": "^3.2.6",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"electron": "37.2.3",
|
||||
"electron-builder": "26.0.15",
|
||||
|
||||
@ -10,29 +10,39 @@ describe('renderSvgInShadowHost', () => {
|
||||
|
||||
// Mock attachShadow
|
||||
Element.prototype.attachShadow = vi.fn().mockImplementation(function (this: HTMLElement) {
|
||||
const shadowRoot = document.createElement('div')
|
||||
// Check if a shadow root already exists to prevent re-creating it.
|
||||
if (this.shadowRoot) {
|
||||
return this.shadowRoot
|
||||
}
|
||||
|
||||
// Create a container that acts as the shadow root.
|
||||
const shadowRootContainer = document.createElement('div')
|
||||
shadowRootContainer.dataset.testid = 'shadow-root'
|
||||
|
||||
Object.defineProperty(this, 'shadowRoot', {
|
||||
value: shadowRoot,
|
||||
value: shadowRootContainer,
|
||||
writable: true,
|
||||
configurable: true
|
||||
})
|
||||
// Simple innerHTML copy for test verification
|
||||
Object.defineProperty(shadowRoot, 'innerHTML', {
|
||||
set(value) {
|
||||
shadowRoot.textContent = value // A simplified mock
|
||||
|
||||
// Mock essential methods like appendChild and innerHTML.
|
||||
// JSDOM doesn't fully implement shadow DOM, so we simulate its behavior.
|
||||
const originalInnerHTMLDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML')
|
||||
Object.defineProperty(shadowRootContainer, 'innerHTML', {
|
||||
set(value: string) {
|
||||
// Clear existing content and parse the new HTML.
|
||||
originalInnerHTMLDescriptor?.set?.call(this, '')
|
||||
const template = document.createElement('template')
|
||||
template.innerHTML = value
|
||||
shadowRootContainer.append(...Array.from(template.content.childNodes))
|
||||
},
|
||||
get() {
|
||||
return shadowRoot.textContent || ''
|
||||
return originalInnerHTMLDescriptor?.get?.call(this) ?? ''
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
|
||||
shadowRoot.appendChild = vi.fn(<T extends Node>(node: T): T => {
|
||||
shadowRoot.append(node)
|
||||
return node
|
||||
})
|
||||
|
||||
return shadowRoot as unknown as ShadowRoot
|
||||
return shadowRootContainer as unknown as ShadowRoot
|
||||
})
|
||||
})
|
||||
|
||||
@ -57,7 +67,7 @@ describe('renderSvgInShadowHost', () => {
|
||||
|
||||
expect(Element.prototype.attachShadow).not.toHaveBeenCalled()
|
||||
// Verify it works with the existing shadow root
|
||||
expect(existingShadowRoot.appendChild).toHaveBeenCalled()
|
||||
expect(existingShadowRoot.innerHTML).toContain('<svg')
|
||||
})
|
||||
|
||||
it('should inject styles and valid SVG content into the shadow DOM', () => {
|
||||
@ -71,20 +81,31 @@ describe('renderSvgInShadowHost', () => {
|
||||
expect(shadowRoot?.querySelector('rect')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should add the xmlns attribute if it is missing', () => {
|
||||
const svgWithoutXmlns = '<svg width="100" height="100"><circle cx="50" cy="50" r="40" /></svg>'
|
||||
renderSvgInShadowHost(svgWithoutXmlns, hostElement)
|
||||
|
||||
const svgElement = hostElement.shadowRoot?.querySelector('svg')
|
||||
expect(svgElement).not.toBeNull()
|
||||
expect(svgElement?.getAttribute('xmlns')).toBe('http://www.w3.org/2000/svg')
|
||||
})
|
||||
|
||||
it('should throw an error if the host element is not available', () => {
|
||||
expect(() => renderSvgInShadowHost('<svg></svg>', null as any)).toThrow(
|
||||
'Host element for SVG rendering is not available.'
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw an error for invalid SVG content', () => {
|
||||
const invalidSvg = '<svg><rect></svg>' // Malformed
|
||||
expect(() => renderSvgInShadowHost(invalidSvg, hostElement)).toThrow(/SVG parsing error/)
|
||||
it('should not throw an error for malformed SVG content due to HTML parser fallback', () => {
|
||||
const invalidSvg = '<svg><rect></svg>' // Malformed, but fixable by the browser's HTML parser
|
||||
expect(() => renderSvgInShadowHost(invalidSvg, hostElement)).not.toThrow()
|
||||
// Also, assert that it successfully rendered something.
|
||||
expect(hostElement.shadowRoot?.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should throw an error for non-SVG content', () => {
|
||||
const nonSvg = '<div>this is not svg</div>'
|
||||
expect(() => renderSvgInShadowHost(nonSvg, hostElement)).toThrow('Invalid SVG content')
|
||||
expect(() => renderSvgInShadowHost(nonSvg, hostElement)).toThrow()
|
||||
})
|
||||
|
||||
it('should not throw an error for empty or whitespace content', () => {
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import { makeSvgSizeAdaptive } from '@renderer/utils'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
/**
|
||||
* Renders an SVG string inside a host element's Shadow DOM to ensure style encapsulation.
|
||||
* This function handles creating the shadow root, injecting base styles for the host,
|
||||
@ -12,15 +15,22 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme
|
||||
throw new Error('Host element for SVG rendering is not available.')
|
||||
}
|
||||
|
||||
// Sanitize the SVG content
|
||||
const sanitizedContent = DOMPurify.sanitize(svgContent, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
RETURN_DOM_FRAGMENT: false,
|
||||
RETURN_DOM: false
|
||||
})
|
||||
|
||||
const shadowRoot = hostElement.shadowRoot || hostElement.attachShadow({ mode: 'open' })
|
||||
|
||||
// Base styles for the host element
|
||||
// Base styles for the host element and the inner SVG
|
||||
const style = document.createElement('style')
|
||||
style.textContent = `
|
||||
:host {
|
||||
padding: 1em;
|
||||
background-color: white;
|
||||
overflow: auto;
|
||||
overflow: hidden; /* Prevent scrollbars, as scaling is now handled */
|
||||
border: 0.5px solid var(--color-code-background);
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
@ -28,34 +38,51 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
`
|
||||
|
||||
// Clear previous content and append new style and SVG
|
||||
// Clear previous content and append new style
|
||||
shadowRoot.innerHTML = ''
|
||||
shadowRoot.appendChild(style)
|
||||
|
||||
// Parse and append the SVG using DOMParser to prevent script execution and check for errors
|
||||
if (svgContent.trim() === '') {
|
||||
if (sanitizedContent.trim() === '') {
|
||||
return
|
||||
}
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(svgContent, 'image/svg+xml')
|
||||
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(sanitizedContent, 'image/svg+xml')
|
||||
const parserError = doc.querySelector('parsererror')
|
||||
if (parserError) {
|
||||
// Throw a specific error that can be caught by the calling component
|
||||
throw new Error(`SVG parsing error: ${parserError.textContent || 'Unknown parsing error'}`)
|
||||
let svgElement: Element = doc.documentElement
|
||||
|
||||
// If parsing fails or the namespace is incorrect, fall back to the more lenient HTML parser.
|
||||
if (parserError || svgElement.namespaceURI !== 'http://www.w3.org/2000/svg') {
|
||||
const tempDiv = document.createElement('div')
|
||||
tempDiv.innerHTML = sanitizedContent
|
||||
const svgFromHtml = tempDiv.querySelector('svg')
|
||||
|
||||
if (svgFromHtml) {
|
||||
// Directly use the DOM node created by the HTML parser.
|
||||
svgElement = svgFromHtml
|
||||
// Ensure the xmlns attribute is present.
|
||||
svgElement.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
||||
} else {
|
||||
// If both parsing methods fail, the SVG content is genuinely invalid.
|
||||
if (parserError) {
|
||||
throw new Error(`SVG parsing error: ${parserError.textContent || 'Unknown parsing error'}`)
|
||||
}
|
||||
throw new Error('Invalid SVG content: The provided string does not contain a valid SVG element.')
|
||||
}
|
||||
}
|
||||
|
||||
const svgElement = doc.documentElement
|
||||
if (svgElement && svgElement.nodeName.toLowerCase() === 'svg') {
|
||||
shadowRoot.appendChild(svgElement.cloneNode(true))
|
||||
} else if (svgContent.trim() !== '') {
|
||||
// Do not throw error for empty content
|
||||
// Type guard
|
||||
if (svgElement instanceof SVGSVGElement) {
|
||||
// Standardize the SVG element for proper scaling
|
||||
makeSvgSizeAdaptive(svgElement)
|
||||
|
||||
// Append the SVG element to the shadow root
|
||||
shadowRoot.appendChild(svgElement)
|
||||
} else {
|
||||
// This path is taken if the content is valid XML but not a valid SVG document
|
||||
// (e.g., root element is not <svg>), or if the fallback parser fails.
|
||||
throw new Error('Invalid SVG content: The provided string is not a valid SVG document.')
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,7 +29,9 @@ import { Pluggable } from 'unified'
|
||||
|
||||
import CodeBlock from './CodeBlock'
|
||||
import Link from './Link'
|
||||
import MarkdownSvgRenderer from './MarkdownSvgRenderer'
|
||||
import rehypeHeadingIds from './plugins/rehypeHeadingIds'
|
||||
import rehypeScalableSvg from './plugins/rehypeScalableSvg'
|
||||
import remarkDisableConstructs from './plugins/remarkDisableConstructs'
|
||||
import Table from './Table'
|
||||
|
||||
@ -113,7 +115,7 @@ const Markdown: FC<Props> = ({ block, postProcess }) => {
|
||||
const rehypePlugins = useMemo(() => {
|
||||
const plugins: Pluggable[] = []
|
||||
if (ALLOWED_ELEMENTS.test(messageContent)) {
|
||||
plugins.push(rehypeRaw)
|
||||
plugins.push(rehypeRaw, rehypeScalableSvg)
|
||||
}
|
||||
plugins.push([rehypeHeadingIds, { prefix: `heading-${block.id}` }])
|
||||
if (mathEngine === 'KaTeX') {
|
||||
@ -148,7 +150,8 @@ const Markdown: FC<Props> = ({ block, postProcess }) => {
|
||||
const hasImage = props?.node?.children?.some((child: any) => child.tagName === 'img')
|
||||
if (hasImage) return <div {...props} />
|
||||
return <p {...props} />
|
||||
}
|
||||
},
|
||||
svg: MarkdownSvgRenderer
|
||||
} as Partial<Components>
|
||||
}, [onSaveCodeBlock, block.id])
|
||||
|
||||
|
||||
76
src/renderer/src/pages/home/Markdown/MarkdownSvgRenderer.tsx
Normal file
76
src/renderer/src/pages/home/Markdown/MarkdownSvgRenderer.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import { ImagePreviewService } from '@renderer/services/ImagePreviewService'
|
||||
import { makeSvgSizeAdaptive } from '@renderer/utils/image'
|
||||
import { Dropdown } from 'antd'
|
||||
import { Eye } from 'lucide-react'
|
||||
import React, { FC, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface SvgProps extends React.SVGProps<SVGSVGElement> {
|
||||
'data-needs-measurement'?: 'true'
|
||||
}
|
||||
|
||||
/**
|
||||
* A smart SVG renderer for Markdown content.
|
||||
*
|
||||
* This component handles two types of SVGs passed from `react-markdown`:
|
||||
*
|
||||
* 1. **Pre-processed SVGs**: Simple SVGs that were already handled by the
|
||||
* `rehypeScalableSvg` plugin. These are rendered directly.
|
||||
*
|
||||
* 2. **SVGs needing measurement**: Complex SVGs are flagged with
|
||||
* `data-needs-measurement`. This component performs a one-time DOM
|
||||
* mutation upon mounting to make them scalable. To prevent React from
|
||||
* reverting these changes during subsequent renders, it stops passing
|
||||
* the original `width` and `height` props after the mutation is complete.
|
||||
*/
|
||||
const MarkdownSvgRenderer: FC<SvgProps> = (props) => {
|
||||
const { 'data-needs-measurement': needsMeasurement, ...restProps } = props
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const isMeasuredRef = useRef(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
if (needsMeasurement && svgRef.current && !isMeasuredRef.current) {
|
||||
// Directly mutate the DOM element to make it adaptive.
|
||||
makeSvgSizeAdaptive(svgRef.current)
|
||||
// Set flag to prevent re-measuring. This does not trigger a re-render.
|
||||
isMeasuredRef.current = true
|
||||
}
|
||||
}, [needsMeasurement])
|
||||
|
||||
const onPreview = useCallback(() => {
|
||||
if (!svgRef.current) return
|
||||
ImagePreviewService.show(svgRef.current, { format: 'svg' })
|
||||
}, [])
|
||||
|
||||
const contextMenuItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'preview',
|
||||
label: t('common.preview'),
|
||||
icon: <Eye size="1rem" />,
|
||||
onClick: onPreview
|
||||
}
|
||||
],
|
||||
[onPreview, t]
|
||||
)
|
||||
|
||||
// Create a mutable copy of props to potentially modify.
|
||||
const finalProps = { ...restProps }
|
||||
|
||||
// If the SVG has been measured and mutated, we prevent React from
|
||||
// re-applying the original width and height attributes on subsequent renders.
|
||||
// This preserves the changes made by `makeSvgSizeAdaptive`.
|
||||
if (isMeasuredRef.current) {
|
||||
delete finalProps.width
|
||||
delete finalProps.height
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items: contextMenuItems }} trigger={['contextMenu']}>
|
||||
<svg ref={svgRef} {...finalProps} />
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
export default MarkdownSvgRenderer
|
||||
@ -68,9 +68,9 @@ vi.mock('../CodeBlock', () => ({
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('../ImagePreview', () => ({
|
||||
vi.mock('@renderer/components/ImageViewer', () => ({
|
||||
__esModule: true,
|
||||
default: (props: any) => <img data-testid="image-preview" {...props} />
|
||||
default: (props: any) => <img data-testid="image-viewer" {...props} />
|
||||
}))
|
||||
|
||||
vi.mock('../Link', () => ({
|
||||
@ -94,12 +94,18 @@ vi.mock('../Table', () => ({
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('../MarkdownSvgRenderer', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: any) => <div data-testid="svg-renderer">{children}</div>
|
||||
}))
|
||||
|
||||
vi.mock('@renderer/components/MarkdownShadowDOMRenderer', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: any) => <div data-testid="shadow-dom">{children}</div>
|
||||
}))
|
||||
|
||||
// Mock plugins
|
||||
vi.mock('remark-alert', () => ({ __esModule: true, default: vi.fn() }))
|
||||
vi.mock('remark-gfm', () => ({ __esModule: true, default: vi.fn() }))
|
||||
vi.mock('remark-cjk-friendly', () => ({ __esModule: true, default: vi.fn() }))
|
||||
vi.mock('remark-math', () => ({ __esModule: true, default: vi.fn() }))
|
||||
@ -113,6 +119,16 @@ vi.mock('../plugins/remarkDisableConstructs', () => ({
|
||||
default: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../plugins/rehypeHeadingIds', () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../plugins/rehypeScalableSvg', () => ({
|
||||
__esModule: true,
|
||||
default: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock ReactMarkdown with realistic rendering
|
||||
vi.mock('react-markdown', () => ({
|
||||
__esModule: true,
|
||||
@ -331,7 +347,7 @@ describe('Markdown', () => {
|
||||
expect(tableComponent).toHaveAttribute('data-block-id', 'test-block-456')
|
||||
})
|
||||
|
||||
it('should integrate ImagePreview component', () => {
|
||||
it('should integrate ImageViewer component', () => {
|
||||
render(<Markdown block={createMainTextBlock()} />)
|
||||
|
||||
expect(screen.getByTestId('has-img-component')).toBeInTheDocument()
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
import type { Element, Root } from 'hast'
|
||||
import { visit } from 'unist-util-visit'
|
||||
|
||||
const isNumeric = (value: unknown): boolean => {
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
return String(parseFloat(value)) === value.trim()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* A Rehype plugin that prepares SVG elements for scalable rendering.
|
||||
*
|
||||
* This plugin classifies SVGs into two categories:
|
||||
*
|
||||
* 1. **Simple SVGs**: Those that already have a `viewBox` or have unitless
|
||||
* numeric `width` and `height` attributes. These are processed directly
|
||||
* in the HAST tree for maximum performance. A `viewBox` is added if
|
||||
* missing, and fixed dimensions are removed.
|
||||
*
|
||||
* 2. **Complex SVGs**: Those without a `viewBox` and with dimensions that
|
||||
* have units (e.g., "100pt", "10em"). These cannot be safely processed
|
||||
* at the data layer. The plugin adds a `data-needs-measurement="true"`
|
||||
* attribute to them, flagging them for runtime processing by a
|
||||
* specialized React component.
|
||||
*
|
||||
* @returns A unified transformer function.
|
||||
*/
|
||||
function rehypeScalableSvg() {
|
||||
return (tree: Root) => {
|
||||
visit(tree, 'element', (node: Element) => {
|
||||
if (node.tagName === 'svg') {
|
||||
const properties = node.properties || {}
|
||||
const hasViewBox = 'viewBox' in properties
|
||||
const width = properties.width as string | undefined
|
||||
const height = properties.height as string | undefined
|
||||
|
||||
// 1. Universally set max-width from the width attribute if it exists.
|
||||
// This is safe for both simple and complex cases.
|
||||
if (width) {
|
||||
const existingStyle = properties.style ? String(properties.style).trim().replace(/;$/, '') : ''
|
||||
const maxWidth = `max-width: ${width}`
|
||||
properties.style = existingStyle ? `${existingStyle}; ${maxWidth}` : maxWidth
|
||||
}
|
||||
|
||||
// 2. Handle viewBox creation for simple, numeric cases.
|
||||
if (!hasViewBox && isNumeric(width) && isNumeric(height)) {
|
||||
properties.viewBox = `0 0 ${width} ${height}`
|
||||
}
|
||||
// 3. Flag complex cases for runtime measurement.
|
||||
else if (!hasViewBox && width && height) {
|
||||
properties['data-needs-measurement'] = 'true'
|
||||
}
|
||||
|
||||
// 4. Reset or clean up attributes.
|
||||
properties.width = '100%'
|
||||
delete properties.height
|
||||
|
||||
node.properties = properties
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default rehypeScalableSvg
|
||||
@ -6,7 +6,8 @@ import {
|
||||
captureScrollableDivAsBlob,
|
||||
captureScrollableDivAsDataURL,
|
||||
compressImage,
|
||||
convertToBase64
|
||||
convertToBase64,
|
||||
makeSvgSizeAdaptive
|
||||
} from '../image'
|
||||
|
||||
// mock 依赖
|
||||
@ -125,4 +126,79 @@ describe('utils/image', () => {
|
||||
expect(func).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('makeSvgSizeAdaptive', () => {
|
||||
const createSvgElement = (svgString: string): SVGElement => {
|
||||
const div = document.createElement('div')
|
||||
div.innerHTML = svgString
|
||||
const svgElement = div.querySelector<SVGElement>('svg')
|
||||
if (!svgElement) {
|
||||
throw new Error(`Test setup error: No <svg> element found in string: "${svgString}"`)
|
||||
}
|
||||
return svgElement
|
||||
}
|
||||
|
||||
// Mock document.body.appendChild to avoid errors in jsdom
|
||||
beforeEach(() => {
|
||||
vi.spyOn(document.body, 'appendChild').mockImplementation(() => ({}) as Node)
|
||||
vi.spyOn(document.body, 'removeChild').mockImplementation(() => ({}) as Node)
|
||||
})
|
||||
|
||||
it('should measure and add viewBox/max-width when viewBox is missing', () => {
|
||||
const svgElement = createSvgElement('<svg width="100pt" height="80pt"></svg>')
|
||||
// Mock the measurement result on the prototype
|
||||
const spy = vi
|
||||
.spyOn(SVGElement.prototype, 'getBoundingClientRect')
|
||||
.mockReturnValue({ width: 133, height: 106 } as DOMRect)
|
||||
|
||||
const result = makeSvgSizeAdaptive(svgElement) as SVGElement
|
||||
|
||||
expect(spy).toHaveBeenCalled()
|
||||
expect(result.getAttribute('viewBox')).toBe('0 0 133 106')
|
||||
expect(result.style.maxWidth).toBe('133px')
|
||||
expect(result.getAttribute('width')).toBe('100%')
|
||||
expect(result.hasAttribute('height')).toBe(false)
|
||||
|
||||
spy.mockRestore() // Clean up the prototype spy
|
||||
})
|
||||
|
||||
it('should use width attribute for max-width when viewBox is present', () => {
|
||||
const svgElement = createSvgElement('<svg viewBox="0 0 50 50" width="100pt" height="80pt"></svg>')
|
||||
const spy = vi.spyOn(SVGElement.prototype, 'getBoundingClientRect') // Spy to ensure it's NOT called
|
||||
|
||||
const result = makeSvgSizeAdaptive(svgElement) as SVGElement
|
||||
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
expect(result.getAttribute('viewBox')).toBe('0 0 50 50')
|
||||
expect(result.style.maxWidth).toBe('100pt')
|
||||
expect(result.getAttribute('width')).toBe('100%')
|
||||
expect(result.hasAttribute('height')).toBe(false)
|
||||
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('should handle measurement failure gracefully', () => {
|
||||
const svgElement = createSvgElement('<svg width="100pt" height="80pt"></svg>')
|
||||
// Mock a failed measurement
|
||||
const spy = vi
|
||||
.spyOn(SVGElement.prototype, 'getBoundingClientRect')
|
||||
.mockReturnValue({ width: 0, height: 0 } as DOMRect)
|
||||
|
||||
const result = makeSvgSizeAdaptive(svgElement) as SVGElement
|
||||
|
||||
expect(result.hasAttribute('viewBox')).toBe(false)
|
||||
expect(result.style.maxWidth).toBe('100pt') // Falls back to width attribute
|
||||
expect(result.getAttribute('width')).toBe('100%')
|
||||
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('should return the element unchanged if it is not an SVGElement', () => {
|
||||
const divElement = document.createElement('div')
|
||||
const originalOuterHTML = divElement.outerHTML
|
||||
const result = makeSvgSizeAdaptive(divElement)
|
||||
|
||||
expect(result.outerHTML).toBe(originalOuterHTML)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -270,3 +270,81 @@ export const svgToSvgBlob = (svgElement: SVGElement): Blob => {
|
||||
const svgData = new XMLSerializer().serializeToString(svgElement)
|
||||
return new Blob([svgData], { type: 'image/svg+xml' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用离屏容器测量 DOM 元素的渲染尺寸
|
||||
* @param element 要测量的元素
|
||||
* @returns 渲染元素的宽度和高度(以像素为单位)
|
||||
*/
|
||||
function measureElementSize(element: Element): { width: number; height: number } {
|
||||
const clone = element.cloneNode(true) as Element
|
||||
|
||||
// 检查元素类型并重置样式
|
||||
if (clone instanceof HTMLElement || clone instanceof SVGElement) {
|
||||
clone.style.width = ''
|
||||
clone.style.height = ''
|
||||
clone.style.position = ''
|
||||
clone.style.visibility = ''
|
||||
}
|
||||
|
||||
// 创建一个离屏容器
|
||||
const container = document.createElement('div')
|
||||
container.style.position = 'absolute'
|
||||
container.style.top = '-9999px'
|
||||
container.style.left = '-9999px'
|
||||
container.style.visibility = 'hidden'
|
||||
|
||||
container.appendChild(clone)
|
||||
document.body.appendChild(container)
|
||||
|
||||
// 测量并清理
|
||||
const rect = clone.getBoundingClientRect()
|
||||
document.body.removeChild(container)
|
||||
|
||||
return { width: rect.width, height: rect.height }
|
||||
}
|
||||
|
||||
/**
|
||||
* 让 SVG 元素在容器内可缩放,用于“预览”功能。
|
||||
* - 补充缺失的 viewBox
|
||||
* - 补充缺失的 max-width style
|
||||
* - 把 width 改为 100%
|
||||
* - 移除 height
|
||||
*/
|
||||
export const makeSvgSizeAdaptive = (element: Element): Element => {
|
||||
// type guard
|
||||
if (!(element instanceof SVGElement)) {
|
||||
return element
|
||||
}
|
||||
|
||||
const hasViewBox = element.hasAttribute('viewBox')
|
||||
const widthStr = element.getAttribute('width')
|
||||
|
||||
let measuredWidth: number | undefined
|
||||
|
||||
// 如果缺少 viewBox 属性,测量元素尺寸来创建
|
||||
if (!hasViewBox) {
|
||||
const renderedSize = measureElementSize(element)
|
||||
if (renderedSize.width > 0 && renderedSize.height > 0) {
|
||||
measuredWidth = renderedSize.width
|
||||
element.setAttribute('viewBox', `0 0 ${renderedSize.width} ${renderedSize.height}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置 max-width
|
||||
// 优先使用测量得到的宽度值,否则回退到 width 属性值
|
||||
if (measuredWidth !== undefined) {
|
||||
element.style.setProperty('max-width', `${measuredWidth}px`)
|
||||
} else if (widthStr) {
|
||||
element.style.setProperty('max-width', widthStr)
|
||||
}
|
||||
|
||||
// 调整 width 和 height
|
||||
element.setAttribute('width', '100%')
|
||||
element.removeAttribute('height')
|
||||
|
||||
// FIXME: 移除 preserveAspectRatio 来避免某些图无法正常预览
|
||||
element.removeAttribute('preserveAspectRatio')
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
@ -8552,6 +8552,7 @@ __metadata:
|
||||
dexie-react-hooks: "npm:^1.1.7"
|
||||
diff: "npm:^7.0.0"
|
||||
docx: "npm:^9.0.2"
|
||||
dompurify: "npm:^3.2.6"
|
||||
dotenv-cli: "npm:^7.4.2"
|
||||
electron: "npm:37.2.3"
|
||||
electron-builder: "npm:26.0.15"
|
||||
@ -11445,7 +11446,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"dompurify@npm:^3.2.5":
|
||||
"dompurify@npm:^3.2.5, dompurify@npm:^3.2.6":
|
||||
version: 3.2.6
|
||||
resolution: "dompurify@npm:3.2.6"
|
||||
dependencies:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user