mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-26 11:44:28 +08:00
refactor: improve rehypeScalableSvg, add MarkdownSvgRenderer
This commit is contained in:
parent
1a83b2c16a
commit
5e12a0ffe0
@ -29,6 +29,7 @@ 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'
|
||||
@ -149,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])
|
||||
|
||||
|
||||
45
src/renderer/src/pages/home/Markdown/MarkdownSvgRenderer.tsx
Normal file
45
src/renderer/src/pages/home/Markdown/MarkdownSvgRenderer.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { makeSvgScalable } from '@renderer/utils/image'
|
||||
import React, { FC, useEffect, useRef, useState } from 'react'
|
||||
|
||||
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 with zero
|
||||
* performance overhead.
|
||||
*
|
||||
* 2. **SVGs needing measurement**: Complex SVGs (e.g., with unit-based
|
||||
* dimensions like "100pt") are flagged with `data-needs-measurement`.
|
||||
* This component will perform a one-time, off-screen measurement for
|
||||
* these SVGs upon mounting to ensure they are rendered correctly and
|
||||
* scalably.
|
||||
*/
|
||||
const MarkdownSvgRenderer: FC<SvgProps> = (props) => {
|
||||
const { 'data-needs-measurement': needsMeasurement, ...restProps } = props
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const [isMeasured, setIsMeasured] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (needsMeasurement && svgRef.current && !isMeasured) {
|
||||
// The element is a real DOM node, we can now measure it.
|
||||
makeSvgScalable(svgRef.current)
|
||||
// Set flag to prevent re-measuring on subsequent renders
|
||||
setIsMeasured(true)
|
||||
}
|
||||
}, [needsMeasurement, isMeasured])
|
||||
|
||||
// For SVGs that need measurement, we render them once with their original
|
||||
// props to allow the ref to capture the DOM element for measurement.
|
||||
// The `useEffect` will then trigger, process the element, and cause a
|
||||
// re-render with the correct scalable attributes.
|
||||
// For simple SVGs, they are rendered correctly from the start.
|
||||
return <svg ref={svgRef} {...restProps} />
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@ -1,19 +1,28 @@
|
||||
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 makes SVG elements scalable.
|
||||
* A Rehype plugin that prepares SVG elements for scalable rendering.
|
||||
*
|
||||
* This plugin traverses the HAST (HTML Abstract Syntax Tree) and performs
|
||||
* the following operations on each `<svg>` element:
|
||||
* This plugin classifies SVGs into two categories:
|
||||
*
|
||||
* 1. Ensures a `viewBox` attribute exists. If it's missing but `width` and
|
||||
* `height` are present, it generates a `viewBox` from them. This is
|
||||
* crucial for making the SVG scalable.
|
||||
* 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. Removes the `width` and `height` attributes. This allows the SVG's size
|
||||
* to be controlled by CSS (e.g., `max-width: 100%`), making it responsive
|
||||
* and preventing it from overflowing its container.
|
||||
* 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.
|
||||
*/
|
||||
@ -23,19 +32,28 @@ function rehypeScalableSvg() {
|
||||
if (node.tagName === 'svg') {
|
||||
const properties = node.properties || {}
|
||||
const hasViewBox = 'viewBox' in properties
|
||||
const width = properties.width as string | number | undefined
|
||||
const height = properties.height as string | number | undefined
|
||||
const width = properties.width as string | undefined
|
||||
const height = properties.height as string | undefined
|
||||
|
||||
if (!hasViewBox && width && height) {
|
||||
const numericWidth = parseFloat(String(width))
|
||||
const numericHeight = parseFloat(String(height))
|
||||
if (!isNaN(numericWidth) && !isNaN(numericHeight)) {
|
||||
properties.viewBox = `0 0 ${numericWidth} ${numericHeight}`
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
// Remove fixed width and height to allow CSS to control the size
|
||||
delete properties.width
|
||||
// 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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user