refactor: improve rehypeScalableSvg, add MarkdownSvgRenderer

This commit is contained in:
one 2025-08-16 17:22:17 +08:00
parent 1a83b2c16a
commit 5e12a0ffe0
4 changed files with 104 additions and 23 deletions

View File

@ -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])

View 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

View File

@ -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()

View File

@ -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