mysticsymbolic.github.io/lib/export-svg.tsx

223 wiersze
5.5 KiB
TypeScript

import React, { useState } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { createGIF } from "./animated-gif";
import { getSvgMetadata, SvgWithBackground } from "./auto-sizing-svg";
import "./export-svg.css";
function getSvgDocument(svgMarkup: string): string {
return [
`<?xml version="1.0" encoding="utf-8"?>`,
"<!-- Generator: https://github.com/toolness/mystic-symbolic -->",
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">',
svgMarkup,
].join("\n");
}
type ProgressHandler = (value: number | null) => void;
type ImageExporter = (
svgEl: SVGSVGElement,
onProgress: ProgressHandler
) => Promise<string>;
/**
* Initiates a download on the user's browser which downloads the given
* SVG element under the given filename, using the given export algorithm.
*/
async function exportImage(
svgRef: React.RefObject<SVGSVGElement>,
basename: string,
ext: string,
onProgress: ProgressHandler,
exporter: ImageExporter
) {
const svgEl = svgRef.current;
if (!svgEl) {
alert("Oops, an error occurred! Please try again later.");
return;
}
const url = await exporter(svgEl, onProgress);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${basename}.${ext}`;
document.body.append(anchor);
anchor.click();
document.body.removeChild(anchor);
onProgress(null);
}
function getCanvasContext2D(
canvas: HTMLCanvasElement
): CanvasRenderingContext2D {
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error(`Unable to get 2D context for canvas!`);
return ctx;
}
function getSvgUrl(svgMarkup: string): string {
return `data:image/svg+xml;utf8,${encodeURIComponent(
getSvgDocument(svgMarkup)
)}`;
}
/**
* Exports the given SVG as an SVG in a data URL.
*/
const exportSvg: ImageExporter = async (svgEl) => getSvgUrl(svgEl.outerHTML);
/**
* Exports the given SVG as a PNG in a data URL.
*/
const exportPng: ImageExporter = async (svgEl, onProgress) => {
const dataURL = await exportSvg(svgEl, onProgress);
return new Promise((resolve, reject) => {
const canvas = document.createElement("canvas");
const img = document.createElement("img");
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
const ctx = getCanvasContext2D(canvas);
ctx.drawImage(img, 0, 0);
resolve(canvas.toDataURL());
};
img.onerror = reject;
img.src = dataURL;
});
};
function drawImage(
canvas: HTMLCanvasElement,
dataURL: string,
scale: number
): Promise<void> {
return new Promise((resolve, reject) => {
const img = document.createElement("img");
img.onload = () => {
const scaledWidth = Math.floor(img.width * scale);
const scaledHeight = Math.floor(img.height * scale);
canvas.width = scaledWidth;
canvas.height = scaledHeight;
const ctx = getCanvasContext2D(canvas);
ctx.drawImage(
img,
0,
0,
img.width,
img.height,
0,
0,
scaledWidth,
scaledHeight
);
resolve();
};
img.onerror = reject;
img.src = dataURL;
});
}
/**
* Exports the given SVG as a GIF in a data URL.
*/
async function exportGif(
animate: ExportableAnimation,
scale: number,
svgEl: SVGSVGElement,
onProgress: (value: number) => void
): Promise<string> {
const fps = animate.fps || 15;
const msecPerFrame = 1000 / fps;
const numFrames = Math.floor(animate.duration / msecPerFrame);
const svgMeta = getSvgMetadata(svgEl);
const render = (animPct: number) => (
<SvgWithBackground {...svgMeta}>
{animate.render(animPct)}
</SvgWithBackground>
);
const gif = createGIF();
for (let i = 0; i < numFrames; i++) {
onProgress(i / numFrames);
const canvas = document.createElement("canvas");
const animPct = i / numFrames;
const markup = renderToStaticMarkup(render(animPct));
const url = getSvgUrl(markup);
await drawImage(canvas, url, scale);
gif.addFrame(canvas, { delay: msecPerFrame });
}
return new Promise((resolve, reject) => {
gif.on("finished", function (blob) {
onProgress(1);
resolve(URL.createObjectURL(blob));
});
gif.render();
});
}
export type AnimationRenderer = (time: number) => JSX.Element;
export type ExportableAnimation = {
duration: number;
fps?: number;
render: AnimationRenderer;
};
const DEFAULT_GIF_SCALE = 0.5;
export const ExportWidget: React.FC<{
svgRef: React.RefObject<SVGSVGElement>;
animate?: ExportableAnimation | false;
gifScale?: number;
basename: string;
}> = ({ svgRef, basename, animate, gifScale }) => {
const [progress, setProgress] = useState<number | null>(null);
if (progress !== null) {
return (
<div className="overlay-wrapper">
<p>Exporting&hellip;</p>
<progress value={progress} />
</div>
);
}
return (
<>
<button
onClick={() =>
exportImage(svgRef, basename, "svg", setProgress, exportSvg)
}
>
Export SVG
</button>{" "}
<button
onClick={() =>
exportImage(svgRef, basename, "png", setProgress, exportPng)
}
>
Export PNG
</button>{" "}
{animate && (
<button
onClick={() =>
exportImage(
svgRef,
basename,
"gif",
setProgress,
exportGif.bind(null, animate, gifScale || DEFAULT_GIF_SCALE)
)
}
>
Export GIF
</button>
)}
</>
);
};