diff --git a/src/renderer/src/pages/home/Markdown/Markdown.tsx b/src/renderer/src/pages/home/Markdown/Markdown.tsx index 544de3b897..47fc77df72 100644 --- a/src/renderer/src/pages/home/Markdown/Markdown.tsx +++ b/src/renderer/src/pages/home/Markdown/Markdown.tsx @@ -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 = ({ block, postProcess }) => { const hasImage = props?.node?.children?.some((child: any) => child.tagName === 'img') if (hasImage) return
return

- } + }, + svg: MarkdownSvgRenderer } as Partial }, [onSaveCodeBlock, block.id]) diff --git a/src/renderer/src/pages/home/Markdown/MarkdownSvgRenderer.tsx b/src/renderer/src/pages/home/Markdown/MarkdownSvgRenderer.tsx new file mode 100644 index 0000000000..a516d17e80 --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/MarkdownSvgRenderer.tsx @@ -0,0 +1,45 @@ +import { makeSvgScalable } from '@renderer/utils/image' +import React, { FC, useEffect, useRef, useState } from 'react' + +interface SvgProps extends React.SVGProps { + '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 = (props) => { + const { 'data-needs-measurement': needsMeasurement, ...restProps } = props + const svgRef = useRef(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 +} + +export default MarkdownSvgRenderer diff --git a/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx b/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx index b7e1ee8b52..b4d832f3fd 100644 --- a/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx +++ b/src/renderer/src/pages/home/Markdown/__tests__/Markdown.test.tsx @@ -68,9 +68,9 @@ vi.mock('../CodeBlock', () => ({ ) })) -vi.mock('../ImagePreview', () => ({ +vi.mock('@renderer/components/ImageViewer', () => ({ __esModule: true, - default: (props: any) => + default: (props: any) => })) vi.mock('../Link', () => ({ @@ -94,12 +94,18 @@ vi.mock('../Table', () => ({ ) })) +vi.mock('../MarkdownSvgRenderer', () => ({ + __esModule: true, + default: ({ children }: any) =>

{children}
+})) + vi.mock('@renderer/components/MarkdownShadowDOMRenderer', () => ({ __esModule: true, default: ({ children }: any) =>
{children}
})) // 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() expect(screen.getByTestId('has-img-component')).toBeInTheDocument() diff --git a/src/renderer/src/pages/home/Markdown/plugins/rehypeScalableSvg.ts b/src/renderer/src/pages/home/Markdown/plugins/rehypeScalableSvg.ts index 8d7959e90a..535075c4dd 100644 --- a/src/renderer/src/pages/home/Markdown/plugins/rehypeScalableSvg.ts +++ b/src/renderer/src/pages/home/Markdown/plugins/rehypeScalableSvg.ts @@ -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 `` 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