diff --git a/apps/web/src/lib/wechat-copy.ts b/apps/web/src/lib/wechat-copy.ts index 0aef653b..35df4d52 100644 --- a/apps/web/src/lib/wechat-copy.ts +++ b/apps/web/src/lib/wechat-copy.ts @@ -104,7 +104,7 @@ const canvasToPng = (canvas: HTMLCanvasElement) => new Promise((resolve, r }, "image/png"); }); -const rasterizeImageElement = async (image: HTMLImageElement) => { +const rasterizeImageElement = async (image: HTMLImageElement, scale = 1) => { if (!image.complete || image.naturalWidth === 0) { await image.decode(); } @@ -113,13 +113,13 @@ const rasterizeImageElement = async (image: HTMLImageElement) => { } const canvas = document.createElement("canvas"); - canvas.width = image.naturalWidth; - canvas.height = image.naturalHeight; + canvas.width = Math.ceil(image.naturalWidth * scale); + canvas.height = Math.ceil(image.naturalHeight * scale); const context = canvas.getContext("2d"); if (!context) { throw new Error("Could not create image canvas"); } - context.drawImage(image, 0, 0); + context.drawImage(image, 0, 0, canvas.width, canvas.height); return canvasToPng(canvas); }; @@ -160,6 +160,79 @@ const convertImageToPng = async (blob: Blob) => { return rasterizeBlob(blob); }; +const getSvgSize = (svg: string) => { + const parsed = new DOMParser().parseFromString(svg, "image/svg+xml"); + const root = parsed.documentElement; + const viewBox = root.getAttribute("viewBox")?.trim().split(/[\s,]+/).map(Number); + const width = Number.parseFloat(root.getAttribute("width") || ""); + const height = Number.parseFloat(root.getAttribute("height") || ""); + const viewBoxWidth = viewBox && viewBox.length === 4 ? viewBox[2] : Number.NaN; + const viewBoxHeight = viewBox && viewBox.length === 4 ? viewBox[3] : Number.NaN; + return { + width: Number.isFinite(viewBoxWidth) ? viewBoxWidth : Number.isFinite(width) ? width : 800, + height: Number.isFinite(viewBoxHeight) ? viewBoxHeight : Number.isFinite(height) ? height : 600, + }; +}; + +const svgToPng = async (svg: string) => { + const size = getSvgSize(svg); + const objectUrl = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" })); + try { + const image = new Image(); + image.src = objectUrl; + await image.decode(); + return { + blob: await rasterizeImageElement(image, 2), + ...size, + }; + } finally { + URL.revokeObjectURL(objectUrl); + } +}; + +const renderMermaidSvg = async (source: string) => { + const { renderMermaidSVG, THEMES } = await import("beautiful-mermaid"); + return renderMermaidSVG(source, { + ...THEMES["zinc-light"], + transparent: true, + font: "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif", + padding: 24, + }); +}; + +const embedMermaidForWeChat = async (root: HTMLElement, editor?: Editor) => { + const codeBlocks = Array.from(root.querySelectorAll("pre > code.language-mermaid")); + if (codeBlocks.length === 0) { + return; + } + + const renderedBlocks = editor + ? Array.from(editor.view.dom.querySelectorAll(".edgeever-mermaid-code-block")) + : []; + await Promise.all(codeBlocks.map(async (codeBlock, index) => { + const source = codeBlock.textContent?.trim(); + const pre = codeBlock.closest("pre"); + if (!source || !pre) { + return; + } + + try { + const renderedSvg = renderedBlocks[index]?.querySelector("svg")?.outerHTML; + const svg = renderedSvg || await renderMermaidSvg(source); + const image = document.createElement("img"); + const { blob, width, height } = await svgToPng(svg); + image.src = await blobToDataUrl(blob); + image.width = Math.round(width); + image.height = Math.round(height); + image.alt = "Mermaid diagram"; + image.style.cssText = "display: block; width: auto; max-width: 100%; height: auto; max-height: 30rem; object-fit: contain; margin: 1em auto;"; + pre.replaceWith(image); + } catch { + // Preserve the Mermaid source as a readable fallback when rendering fails. + } + })); +}; + const isSameOrigin = (source: string) => { try { return new URL(source, window.location.href).origin === window.location.origin; @@ -205,6 +278,7 @@ export const buildWeChatClipboardHtml = async (editor: Editor) => { container.innerHTML = editor.getHTML(); const editorTheme = editor.view.dom.closest("[data-editor-theme]")?.dataset.editorTheme; applyInlineStyles(container, editorTheme); + await embedMermaidForWeChat(container, editor); const originalImages = Array.from(editor.view.dom.querySelectorAll("img")); await embedImagesForWeChat(container, originalImages); return container.outerHTML; @@ -242,6 +316,7 @@ export const copyMarkdownToWeChat = async (markdown: string) => { const container = document.createElement("div"); container.innerHTML = marked.parse(markdown, { async: false, gfm: true, breaks: false }); applyInlineStyles(container); + await embedMermaidForWeChat(container); await embedImagesForWeChat(container); await copyHtmlToClipboard(container.outerHTML, container.textContent ?? ""); };