From 66b72e51490eba5f7b142516849d0881b718ad76 Mon Sep 17 00:00:00 2001 From: one Date: Sat, 16 Aug 2025 13:23:38 +0800 Subject: [PATCH] refactor(Svg): make svg preview scalable --- src/renderer/src/components/Preview/utils.ts | 18 ++++- .../src/utils/__tests__/image.test.ts | 75 ++++++++++++++++++- src/renderer/src/utils/image.ts | 30 ++++++++ 3 files changed, 118 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/Preview/utils.ts b/src/renderer/src/components/Preview/utils.ts index db5ba3457b..5b9e23cf96 100644 --- a/src/renderer/src/components/Preview/utils.ts +++ b/src/renderer/src/components/Preview/utils.ts @@ -1,3 +1,5 @@ +import { makeSvgScalable } from '@renderer/utils' + /** * 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, @@ -14,13 +16,13 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme 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,20 +30,24 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme width: 100%; height: 100%; } + svg { max-width: 100%; + max-height: 100%; + width: auto; 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() === '') { return } + + // Parse and append the SVG using DOMParser to prevent script execution and check for errors const parser = new DOMParser() const doc = parser.parseFromString(svgContent, 'image/svg+xml') @@ -53,6 +59,10 @@ export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLEleme const svgElement = doc.documentElement if (svgElement && svgElement.nodeName.toLowerCase() === 'svg') { + // Standardize the SVG element for proper scaling + makeSvgScalable(svgElement) + + // Append the SVG element to the shadow root shadowRoot.appendChild(svgElement.cloneNode(true)) } else if (svgContent.trim() !== '') { // Do not throw error for empty content diff --git a/src/renderer/src/utils/__tests__/image.test.ts b/src/renderer/src/utils/__tests__/image.test.ts index fc658be50b..0893bbabb2 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, + makeSvgScalable } from '../image' // mock 依赖 @@ -125,4 +126,76 @@ describe('utils/image', () => { expect(func).not.toHaveBeenCalled() }) }) + + describe('makeSvgScalable', () => { + const createSvgElement = (svgString: string): SVGElement => { + const div = document.createElement('div') + div.innerHTML = svgString + return div.querySelector('svg') as SVGElement + } + + it('should add viewBox and remove width/height when viewBox is missing', () => { + const svgElement = createSvgElement('') + const result = makeSvgScalable(svgElement) + + expect(result.getAttribute('viewBox')).toBe('0 0 800 600') + expect(result.hasAttribute('width')).toBe(false) + expect(result.hasAttribute('height')).toBe(false) + }) + + it('should not overwrite existing viewBox but still remove width/height', () => { + const svgElement = createSvgElement('') + const result = makeSvgScalable(svgElement) + + expect(result.getAttribute('viewBox')).toBe('0 0 50 50') + expect(result.hasAttribute('width')).toBe(false) + expect(result.hasAttribute('height')).toBe(false) + }) + + it('should not add viewBox for non-numeric width/height but still remove them', () => { + const svgElement = createSvgElement('') + const result = makeSvgScalable(svgElement) + + expect(result.hasAttribute('viewBox')).toBe(false) + expect(result.hasAttribute('width')).toBe(false) + expect(result.hasAttribute('height')).toBe(false) + }) + + it('should do nothing if width, height, and viewBox are missing', () => { + const svgElement = createSvgElement('') + const originalOuterHTML = svgElement.outerHTML + const result = makeSvgScalable(svgElement) + + // Check that no attributes were added + expect(result.hasAttribute('viewBox')).toBe(false) + expect(result.hasAttribute('width')).toBe(false) + expect(result.hasAttribute('height')).toBe(false) + // Check that the content is unchanged + expect(result.outerHTML).toBe(originalOuterHTML) + }) + + it('should not add viewBox if only one dimension is present', () => { + const svgElement = createSvgElement('') + const result = makeSvgScalable(svgElement) + + expect(result.hasAttribute('viewBox')).toBe(false) + expect(result.hasAttribute('height')).toBe(false) + }) + + it('should return the element unchanged if it is not an SVGElement', () => { + const divElement = document.createElement('div') + divElement.setAttribute('width', '100') + divElement.setAttribute('height', '100') + + const originalOuterHTML = divElement.outerHTML + const result = makeSvgScalable(divElement) + + // Check that the element is the same object + expect(result).toBe(divElement) + // Check that the content is unchanged + expect(result.outerHTML).toBe(originalOuterHTML) + // Verify no viewBox was added + expect(result.hasAttribute('viewBox')).toBe(false) + }) + }) }) diff --git a/src/renderer/src/utils/image.ts b/src/renderer/src/utils/image.ts index a6ff7db536..a94df580f6 100644 --- a/src/renderer/src/utils/image.ts +++ b/src/renderer/src/utils/image.ts @@ -270,3 +270,33 @@ export const svgToSvgBlob = (svgElement: SVGElement): Blob => { const svgData = new XMLSerializer().serializeToString(svgElement) return new Blob([svgData], { type: 'image/svg+xml' }) } + +/** + * 确保 SVG 元素有 viewBox 并且移除固定的 width/height 属性。 + * 用于“预览”功能,让 SVG 在容器内可缩放。 + */ +export const makeSvgScalable = (element: Element): Element => { + // Type Guard: Only proceed if the element is actually an SVGElement. + if (!(element instanceof SVGElement)) { + return element + } + + const hasViewBox = element.hasAttribute('viewBox') + const width = element.getAttribute('width') + const height = element.getAttribute('height') + + // 缺少 viewBox 但存在 width 和 height 属性时创建 viewBox + if (!hasViewBox && width && height) { + const numericWidth = parseFloat(width) + const numericHeight = parseFloat(height) + if (!isNaN(numericWidth) && !isNaN(numericHeight)) { + element.setAttribute('viewBox', `0 0 ${numericWidth} ${numericHeight}`) + } + } + + // 移除固定的 width 和 height 属性,让 CSS 控制元素尺寸 + element.removeAttribute('width') + element.removeAttribute('height') + + return element +}