diff --git a/bricks/icons/docs/eo-svg-icon.md b/bricks/icons/docs/eo-svg-icon.md index 02ee1c94a..08b816cf6 100644 --- a/bricks/icons/docs/eo-svg-icon.md +++ b/bricks/icons/docs/eo-svg-icon.md @@ -8,3 +8,62 @@ properties: fontSize: 32px color: var(--palette-green-6) ``` + +## Examples + +### SvgContent + +```yaml preview +- brick: h2 + properties: + textContent: 预览 SVG 图标 +- brick: p + properties: + textContent: 选择一张 SVG 图片,并预览它生成的图标。注意在右上角切换主题模式,以便验证其在深色和浅色主题下都能按预期显示。 +- brick: eo-upload-file + properties: + style: + width: 300px + uploadDraggable: true + accept: image/svg+xml + draggableUploadTip: 选择一张 SVG 图片 + events: + change: + - if: <% EVENT.detail?.length %> + useProvider: basic.set-timeout + args: + - 0 + - <% EVENT.detail[EVENT.detail.length - 1].file.text() %> + callback: + success: + target: eo-svg-icon + properties: + svgContent: <% EVENT.detail %> +- brick: eo-svg-icon + properties: + svgContent: | + + 编组 11 + + + + + + + + + + + + + + + + + + + + style: + color: var(--palette-blue-6) + fontSize: 80px +``` diff --git a/bricks/icons/src/shared/SvgCache.ts b/bricks/icons/src/shared/SvgCache.ts index 831fc654d..c5478d31f 100644 --- a/bricks/icons/src/shared/SvgCache.ts +++ b/bricks/icons/src/shared/SvgCache.ts @@ -14,6 +14,65 @@ interface ResolveIconOptions { replaceSource?(source: string): string; } +export function constructSvgElement( + content: string, + retryable: false, + options?: ResolveIconOptions +): SVGSVGElement | null; +export function constructSvgElement( + content: string, + retryable: true, + options?: ResolveIconOptions +): SVGResult; +export function constructSvgElement( + content: string, + retryable: boolean, + options?: ResolveIconOptions +): SVGResult | null { + const div = document.createElement("div"); + div.innerHTML = content; + + const svg = div.firstElementChild; + if (svg?.tagName?.toLowerCase() !== "svg") + return retryable ? CACHEABLE_ERROR : null; + + if (!parser) parser = new DOMParser(); + const doc = parser.parseFromString(svg.outerHTML, "text/html"); + + const svgEl = doc.body.querySelector("svg"); + if (!svgEl) return retryable ? CACHEABLE_ERROR : null; + + const titles = svgEl.querySelectorAll("title"); + for (const title of titles) { + title.remove(); + } + + if (options?.currentColor) { + const colorProps = [ + "color", + "fill", + "stroke", + "stop-color", + "flood-color", + "lighting-color", + ]; + for (const prop of colorProps) { + const elements = svgEl.querySelectorAll( + `[${prop}]:not([${prop}="none"])` + ); + for (const e of elements) { + if (!belongToMask(e, svgEl)) { + e.setAttribute(prop, "currentColor"); + } + } + } + } + + svgEl.setAttribute("width", "1em"); + svgEl.setAttribute("height", "1em"); + return document.adoptNode(svgEl); +} + /** Given a URL, this function returns the resulting SVG element or an appropriate error symbol. */ async function resolveIcon( url: string, @@ -29,47 +88,8 @@ async function resolveIcon( } try { - const div = document.createElement("div"); - div.innerHTML = await fileData.text(); - - const svg = div.firstElementChild; - if (svg?.tagName?.toLowerCase() !== "svg") return CACHEABLE_ERROR; - - if (!parser) parser = new DOMParser(); - const doc = parser.parseFromString(svg.outerHTML, "text/html"); - - const svgEl = doc.body.querySelector("svg"); - if (!svgEl) return CACHEABLE_ERROR; - - const titles = svgEl.querySelectorAll("title"); - for (const title of titles) { - title.remove(); - } - - if (options?.currentColor) { - const colorProps = [ - "color", - "fill", - "stroke", - "stop-color", - "flood-color", - "lighting-color", - ]; - for (const prop of colorProps) { - const elements = svgEl.querySelectorAll( - `[${prop}]:not([${prop}="none"])` - ); - for (const e of elements) { - if (!belongToMask(e, svgEl)) { - e.setAttribute(prop, "currentColor"); - } - } - } - } - - svgEl.setAttribute("width", "1em"); - svgEl.setAttribute("height", "1em"); - return document.adoptNode(svgEl); + const content = await fileData.text(); + return constructSvgElement(content, true, options); } catch { return CACHEABLE_ERROR; } diff --git a/bricks/icons/src/svg-icon/index.spec.ts b/bricks/icons/src/svg-icon/index.spec.ts index a48fd68c6..95f3c9454 100644 --- a/bricks/icons/src/svg-icon/index.spec.ts +++ b/bricks/icons/src/svg-icon/index.spec.ts @@ -2,12 +2,7 @@ import { describe, test, expect } from "@jest/globals"; import "./index.js"; import type { SvgIcon } from "./index.js"; -(global as any).fetch = jest.fn(() => - Promise.resolve({ - ok: true, - text: () => - Promise.resolve( - ` +const svgContent = ` @@ -17,8 +12,12 @@ import type { SvgIcon } from "./index.js"; -`.replace(/>\s+<") - ), +`.replace(/>\s+<"); + +(global as any).fetch = jest.fn(() => + Promise.resolve({ + ok: true, + text: () => Promise.resolve(svgContent), }) ); @@ -105,4 +104,64 @@ describe("eo-svg-icon", () => { (element as any)._render(); expect(fetch).not.toBeCalled(); }); + + test("use svg content", async () => { + const element = document.createElement("eo-svg-icon") as SvgIcon; + element.svgContent = svgContent; + + expect(element.shadowRoot).toBeFalsy(); + document.body.appendChild(element); + expect(element.shadowRoot).toBeTruthy(); + await (global as any).flushPromises(); + expect(element.shadowRoot?.childNodes).toMatchInlineSnapshot(` + NodeList [ + , + + + + + + + + + + , + ] + `); + document.body.removeChild(element); + expect(element.shadowRoot?.childNodes.length).toBe(0); + + // Re-connect + document.body.appendChild(element); + await (global as any).flushPromises(); + expect(element.shadowRoot?.childNodes.length).toBe(2); + document.body.removeChild(element); + expect(element.shadowRoot?.childNodes.length).toBe(0); + }); }); diff --git a/bricks/icons/src/svg-icon/index.ts b/bricks/icons/src/svg-icon/index.ts index 58bfbfb68..8c53fb823 100644 --- a/bricks/icons/src/svg-icon/index.ts +++ b/bricks/icons/src/svg-icon/index.ts @@ -5,7 +5,7 @@ import { type EventEmitter, } from "@next-core/element"; import { wrapLocalBrick } from "@next-core/react-element"; -import { getIcon } from "../shared/SvgCache.js"; +import { constructSvgElement, getIcon } from "../shared/SvgCache.js"; import { getImageUrl } from "../shared/getImageUrl.js"; import type { IconEvents, IconEventsMapping } from "../shared/interfaces.js"; import sharedStyleText from "../shared/icons.shadow.css"; @@ -14,6 +14,7 @@ const { defineElement, property, event } = createDecorators(); export interface SvgIconProps { imgSrc?: string; + svgContent?: string; noPublicRoot?: boolean; } @@ -23,6 +24,8 @@ class SvgIcon extends NextElement implements SvgIconProps { /** 图标地址 */ @property() accessor imgSrc: string | undefined; + @property() accessor svgContent: string | undefined; + @property({ type: Boolean, }) @@ -60,13 +63,18 @@ class SvgIcon extends NextElement implements SvgIconProps { if (!this.isConnected || !this.shadowRoot) { return; } - const url = getImageUrl(this.imgSrc, this.noPublicRoot); - - const svg = await getIcon(url, { currentColor: true }); - if (url !== getImageUrl(this.imgSrc, this.noPublicRoot)) { - // The icon has changed during `await getIcon(...)` - return; + let svg: SVGElement | null = null; + if (this.svgContent) { + svg = constructSvgElement(this.svgContent, false, { currentColor: true }); + } else { + const url = getImageUrl(this.imgSrc, this.noPublicRoot); + svg = await getIcon(url, { currentColor: true }); + if (url !== getImageUrl(this.imgSrc, this.noPublicRoot)) { + // The icon has changed during `await getIcon(...)` + return; + } } + // Currently React can't render mixed React Component and DOM nodes which are siblings, // so we manually construct the DOM. const nodes: Node[] = []; diff --git a/shared/icons/README.md b/shared/icons/README.md index b4a9c04e6..8d10a77c7 100644 --- a/shared/icons/README.md +++ b/shared/icons/README.md @@ -17,3 +17,9 @@ https://bricks.js.org/icons/ - 默认都是「字体图标」,和字体一样,本身不提供颜色,而是跟随页面文本颜色的设定(原始 SVG 文件仅使用一种颜色); - 需要固定原始颜色(通常会使用多种颜色)的图标,需要放置在特定的分类下(以 `colored-` 开头),这些图标保留图标原始颜色,不能由消费端指定其他颜色; - 可以让一个图标中的一部分内容带有透明度设置,变相实现类似「双色」的能力(一深一浅)。 + +--- + +不确定你的 SVG 图片转换为图标后是否符合预期?点击以下链接并导入 SVG 图片提前在线预览: + +https://bricks.js.org/playground/?mode=yaml&example=icons%2Feo-svg-icon%2Fsvgcontent&collapsed=1