From 72d0fea3a1aef2eeac90cca626af47a8af497fe5 Mon Sep 17 00:00:00 2001 From: one Date: Sat, 16 Aug 2025 23:19:47 +0800 Subject: [PATCH] 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 73af8fbb8c58b7f3986606e5f249113d1cbb9fdf. * refactor: use context menu in MarkdownSvgRenderer * refactor: remove preserveAspectRatio from svg --- package.json | 1 + .../Preview/__tests__/utils.test.ts | 57 +++++++++----- src/renderer/src/components/Preview/utils.ts | 65 +++++++++++----- .../src/pages/home/Markdown/Markdown.tsx | 7 +- .../home/Markdown/MarkdownSvgRenderer.tsx | 76 ++++++++++++++++++ .../home/Markdown/__tests__/Markdown.test.tsx | 22 +++++- .../Markdown/plugins/rehypeScalableSvg.ts | 65 ++++++++++++++++ .../src/utils/__tests__/image.test.ts | 78 ++++++++++++++++++- src/renderer/src/utils/image.ts | 78 +++++++++++++++++++ yarn.lock | 3 +- 10 files changed, 408 insertions(+), 44 deletions(-) create mode 100644 src/renderer/src/pages/home/Markdown/MarkdownSvgRenderer.tsx create mode 100644 src/renderer/src/pages/home/Markdown/plugins/rehypeScalableSvg.ts diff --git a/package.json b/package.json index a824984845..70ed89485c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/renderer/src/components/Preview/__tests__/utils.test.ts b/src/renderer/src/components/Preview/__tests__/utils.test.ts index c9722c33d4..bfa67c1516 100644 --- a/src/renderer/src/components/Preview/__tests__/utils.test.ts +++ b/src/renderer/src/components/Preview/__tests__/utils.test.ts @@ -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((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(' { @@ -71,20 +81,31 @@ describe('renderSvgInShadowHost', () => { expect(shadowRoot?.querySelector('rect')).not.toBeNull() }) + it('should add the xmlns attribute if it is missing', () => { + const svgWithoutXmlns = '' + 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('', null as any)).toThrow( 'Host element for SVG rendering is not available.' ) }) - it('should throw an error for invalid SVG content', () => { - const invalidSvg = '' // 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 = '' // 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 = '
this is not svg
' - expect(() => renderSvgInShadowHost(nonSvg, hostElement)).toThrow('Invalid SVG content') + expect(() => renderSvgInShadowHost(nonSvg, hostElement)).toThrow() }) it('should not throw an error for empty or whitespace content', () => { diff --git a/src/renderer/src/components/Preview/utils.ts b/src/renderer/src/components/Preview/utils.ts index db5ba3457b..e400062911 100644 --- a/src/renderer/src/components/Preview/utils.ts +++ b/src/renderer/src/components/Preview/utils.ts @@ -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 ), or if the fallback parser fails. throw new Error('Invalid SVG content: The provided string is not a valid SVG document.') } } diff --git a/src/renderer/src/pages/home/Markdown/Markdown.tsx b/src/renderer/src/pages/home/Markdown/Markdown.tsx index 98a24b8735..47fc77df72 100644 --- a/src/renderer/src/pages/home/Markdown/Markdown.tsx +++ b/src/renderer/src/pages/home/Markdown/Markdown.tsx @@ -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 = ({ 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 = ({ 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..1313b95a45 --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/MarkdownSvgRenderer.tsx @@ -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 { + '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 = (props) => { + const { 'data-needs-measurement': needsMeasurement, ...restProps } = props + const svgRef = useRef(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: , + 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 ( + + + + ) +} + +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 new file mode 100644 index 0000000000..535075c4dd --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/plugins/rehypeScalableSvg.ts @@ -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 diff --git a/src/renderer/src/utils/__tests__/image.test.ts b/src/renderer/src/utils/__tests__/image.test.ts index fc658be50b..8ac56beae6 100644 --- a/src/renderer/src/utils/__tests__/image.test.ts +++ b/src/renderer/src/utils/__tests__/image.test.ts @@ -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('svg') + if (!svgElement) { + throw new Error(`Test setup error: No 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('') + // 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('') + 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('') + // 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) + }) + }) }) diff --git a/src/renderer/src/utils/image.ts b/src/renderer/src/utils/image.ts index a6ff7db536..533f3defe1 100644 --- a/src/renderer/src/utils/image.ts +++ b/src/renderer/src/utils/image.ts @@ -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 +} diff --git a/yarn.lock b/yarn.lock index 276a523034..3a27d98704 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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: