SVG export (#598)
* first draft of export to SVG. WIP * enabled text rendeing - which is not quite right atm * placeholder svg icon * size the canvas based on the bounding box of elements * Do not add opacity attributes if default * render background rect * Ensure arrows are in the same SVG group * parse font-size from font * export web fonts * use fixed locations for fonts * Rename export functions * renamed export file * oops broke the icon.
This commit is contained in:
parent
321e4022b0
commit
97b11b0f53
@ -12,6 +12,7 @@
|
||||
"copyToClipboard": "In die Zwischenablage kopieren",
|
||||
"export": "Export",
|
||||
"exportToPng": "Als PNG exportieren",
|
||||
"exportToSvg": "Als SVG exportieren",
|
||||
"getShareableLink": "Teilbaren Link erhalten",
|
||||
"load": "Laden",
|
||||
"save": "Speichern"
|
||||
|
@ -45,6 +45,7 @@
|
||||
"clearReset": "Clear the canvas & reset background color",
|
||||
"export": "Export",
|
||||
"exportToPng": "Export to PNG",
|
||||
"exportToSvg": "Export to SVG",
|
||||
"copyToClipboard": "Copy to clipboard",
|
||||
"save": "Save",
|
||||
"load": "Load",
|
||||
|
@ -45,6 +45,7 @@
|
||||
"clearReset": "Limpiar lienzo y reiniciar el color de fondo",
|
||||
"export": "Exportar",
|
||||
"exportToPng": "Exportar a PNG",
|
||||
"exportToSvg": "Exportar a SVG",
|
||||
"copyToClipboard": "Copiar al portapapeles",
|
||||
"save": "Guardar",
|
||||
"load": "Cargar",
|
||||
|
@ -41,6 +41,7 @@
|
||||
"clearReset": "Effacer le canvas & réinitialiser la couleur d'arrière-plan",
|
||||
"export": "Exporter",
|
||||
"exportToPng": "Exporter en PNG",
|
||||
"exportToSvg": "Exporter en SVG",
|
||||
"copyToClipboard": "Copier dans le presse-papier",
|
||||
"save": "Sauvegarder",
|
||||
"load": "Ouvrir",
|
||||
|
@ -41,6 +41,7 @@
|
||||
"clearReset": "Limpar o canvas e redefinir a cor de fundo",
|
||||
"export": "Exportar",
|
||||
"exportToPng": "Exportar em PNG",
|
||||
"exportToSvg": "Exportar em SVG",
|
||||
"copyToClipboard": "Copiar para o clipboard",
|
||||
"save": "Guardar",
|
||||
"load": "Carregar",
|
||||
|
@ -4,11 +4,11 @@ import React, { useState, useEffect, useRef } from "react";
|
||||
|
||||
import { Modal } from "./Modal";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { clipboard, exportFile, downloadFile, link } from "./icons";
|
||||
import { clipboard, exportFile, downloadFile, svgFile, link } from "./icons";
|
||||
import { Island } from "./Island";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { getExportCanvasPreview } from "../scene/getExportCanvasPreview";
|
||||
import { exportToCanvas } from "../scene/export";
|
||||
import { ActionsManagerInterface, UpdaterFn } from "../actions/types";
|
||||
import Stack from "./Stack";
|
||||
|
||||
@ -36,6 +36,7 @@ function ExportModal({
|
||||
actionManager,
|
||||
syncActionResult,
|
||||
onExportToPng,
|
||||
onExportToSvg,
|
||||
onExportToClipboard,
|
||||
onExportToBackend,
|
||||
onCloseRequest,
|
||||
@ -46,6 +47,7 @@ function ExportModal({
|
||||
actionManager: ActionsManagerInterface;
|
||||
syncActionResult: UpdaterFn;
|
||||
onExportToPng: ExportCB;
|
||||
onExportToSvg: ExportCB;
|
||||
onExportToClipboard: ExportCB;
|
||||
onExportToBackend: ExportCB;
|
||||
onCloseRequest: () => void;
|
||||
@ -70,7 +72,7 @@ function ExportModal({
|
||||
|
||||
useEffect(() => {
|
||||
const previewNode = previewRef.current;
|
||||
const canvas = getExportCanvasPreview(exportedElements, {
|
||||
const canvas = exportToCanvas(exportedElements, {
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
exportPadding,
|
||||
@ -136,6 +138,13 @@ function ExportModal({
|
||||
onClick={() => onExportToPng(exportedElements, scale)}
|
||||
ref={pngButton}
|
||||
/>
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={svgFile}
|
||||
title={t("buttons.exportToSvg")}
|
||||
aria-label={t("buttons.exportToSvg")}
|
||||
onClick={() => onExportToSvg(exportedElements, scale)}
|
||||
/>
|
||||
{probablySupportsClipboard && (
|
||||
<ToolButton
|
||||
type="button"
|
||||
@ -213,6 +222,7 @@ export function ExportDialog({
|
||||
actionManager,
|
||||
syncActionResult,
|
||||
onExportToPng,
|
||||
onExportToSvg,
|
||||
onExportToClipboard,
|
||||
onExportToBackend,
|
||||
}: {
|
||||
@ -222,6 +232,7 @@ export function ExportDialog({
|
||||
actionManager: ActionsManagerInterface;
|
||||
syncActionResult: UpdaterFn;
|
||||
onExportToPng: ExportCB;
|
||||
onExportToSvg: ExportCB;
|
||||
onExportToClipboard: ExportCB;
|
||||
onExportToBackend: ExportCB;
|
||||
}) {
|
||||
@ -257,6 +268,7 @@ export function ExportDialog({
|
||||
actionManager={actionManager}
|
||||
syncActionResult={syncActionResult}
|
||||
onExportToPng={onExportToPng}
|
||||
onExportToSvg={onExportToSvg}
|
||||
onExportToClipboard={onExportToClipboard}
|
||||
onExportToBackend={onExportToBackend}
|
||||
onCloseRequest={handleClose}
|
||||
|
@ -85,3 +85,12 @@ export const downloadFile = (
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const svgFile = (
|
||||
<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 50 50">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M 19 8 L 19 20 L 31 20 L 31 8 Z M 3 11 C 1.34375 11 0 12.34375 0 14 C 0 15.65625 1.34375 17 3 17 C 4.300781 17 5.398438 16.160156 5.8125 15 L 17 15 C 12.121094 17.609375 8.785156 22.492188 8.125 28 L 10.15625 28 C 10.804688 23.21875 13.734375 18.980469 18 16.71875 L 18 13 L 5.8125 13 C 5.398438 11.839844 4.300781 11 3 11 Z M 47 11 C 45.699219 11 44.601563 11.839844 44.1875 13 L 32 13 L 32 16.71875 C 36.269531 18.976563 39.195313 23.203125 39.84375 28 L 41.875 28 C 41.21875 22.476563 37.882813 17.609375 33 15 L 44.1875 15 C 44.601563 16.160156 45.699219 17 47 17 C 48.65625 17 50 15.65625 50 14 C 50 12.34375 48.65625 11 47 11 Z M 3 29 L 3 41 L 15 41 L 15 29 Z M 35 29 L 35 41 L 47 41 L 47 29 Z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { getExportCanvasPreview } from "../src/scene/getExportCanvasPreview";
|
||||
import { exportToCanvas } from "./scene/export";
|
||||
|
||||
const { registerFont, createCanvas } = require("canvas");
|
||||
|
||||
@ -58,7 +58,7 @@ const elements = [
|
||||
|
||||
registerFont("./public/FG_Virgil.ttf", { family: "Virgil" });
|
||||
registerFont("./public/Cascadia.ttf", { family: "Cascadia" });
|
||||
const canvas = getExportCanvasPreview(
|
||||
const canvas = exportToCanvas(
|
||||
elements as any,
|
||||
{
|
||||
exportBackground: true,
|
||||
|
@ -564,6 +564,16 @@ export class App extends React.Component<any, AppState> {
|
||||
scale,
|
||||
});
|
||||
}}
|
||||
onExportToSvg={(exportedElements, scale) => {
|
||||
if (this.canvas) {
|
||||
exportCanvas("svg", exportedElements, this.canvas, {
|
||||
exportBackground: this.state.exportBackground,
|
||||
name: this.state.name,
|
||||
viewBackgroundColor: this.state.viewBackgroundColor,
|
||||
scale,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onExportToClipboard={(exportedElements, scale) => {
|
||||
if (this.canvas)
|
||||
exportCanvas("clipboard", exportedElements, this.canvas, {
|
||||
|
@ -7,6 +7,119 @@ import {
|
||||
} from "../element/bounds";
|
||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||
import { Drawable } from "roughjs/bin/core";
|
||||
import { RoughSVG } from "roughjs/bin/svg";
|
||||
import { RoughGenerator } from "roughjs/bin/generator";
|
||||
import { SVG_NS } from "../utils";
|
||||
|
||||
function generateElement(
|
||||
element: ExcalidrawElement,
|
||||
generator: RoughGenerator,
|
||||
) {
|
||||
if (!element.shape) {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
element.shape = generator.rectangle(
|
||||
0,
|
||||
0,
|
||||
element.width,
|
||||
element.height,
|
||||
{
|
||||
stroke: element.strokeColor,
|
||||
fill:
|
||||
element.backgroundColor === "transparent"
|
||||
? undefined
|
||||
: element.backgroundColor,
|
||||
fillStyle: element.fillStyle,
|
||||
strokeWidth: element.strokeWidth,
|
||||
roughness: element.roughness,
|
||||
seed: element.seed,
|
||||
},
|
||||
);
|
||||
break;
|
||||
case "diamond": {
|
||||
const [
|
||||
topX,
|
||||
topY,
|
||||
rightX,
|
||||
rightY,
|
||||
bottomX,
|
||||
bottomY,
|
||||
leftX,
|
||||
leftY,
|
||||
] = getDiamondPoints(element);
|
||||
element.shape = generator.polygon(
|
||||
[
|
||||
[topX, topY],
|
||||
[rightX, rightY],
|
||||
[bottomX, bottomY],
|
||||
[leftX, leftY],
|
||||
],
|
||||
{
|
||||
stroke: element.strokeColor,
|
||||
fill:
|
||||
element.backgroundColor === "transparent"
|
||||
? undefined
|
||||
: element.backgroundColor,
|
||||
fillStyle: element.fillStyle,
|
||||
strokeWidth: element.strokeWidth,
|
||||
roughness: element.roughness,
|
||||
seed: element.seed,
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "ellipse":
|
||||
element.shape = generator.ellipse(
|
||||
element.width / 2,
|
||||
element.height / 2,
|
||||
element.width,
|
||||
element.height,
|
||||
{
|
||||
stroke: element.strokeColor,
|
||||
fill:
|
||||
element.backgroundColor === "transparent"
|
||||
? undefined
|
||||
: element.backgroundColor,
|
||||
fillStyle: element.fillStyle,
|
||||
strokeWidth: element.strokeWidth,
|
||||
roughness: element.roughness,
|
||||
seed: element.seed,
|
||||
curveFitting: 1,
|
||||
},
|
||||
);
|
||||
break;
|
||||
case "arrow": {
|
||||
const [x1, y1, x2, y2, x3, y3, x4, y4] = getArrowPoints(element);
|
||||
const options = {
|
||||
stroke: element.strokeColor,
|
||||
strokeWidth: element.strokeWidth,
|
||||
roughness: element.roughness,
|
||||
seed: element.seed,
|
||||
};
|
||||
element.shape = [
|
||||
// \
|
||||
generator.line(x3, y3, x2, y2, options),
|
||||
// -----
|
||||
generator.line(x1, y1, x2, y2, options),
|
||||
// /
|
||||
generator.line(x4, y4, x2, y2, options),
|
||||
];
|
||||
break;
|
||||
}
|
||||
case "line": {
|
||||
const [x1, y1, x2, y2] = getLinePoints(element);
|
||||
const options = {
|
||||
stroke: element.strokeColor,
|
||||
strokeWidth: element.strokeWidth,
|
||||
roughness: element.roughness,
|
||||
seed: element.seed,
|
||||
};
|
||||
element.shape = generator.line(x1, y1, x2, y2, options);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function renderElement(
|
||||
element: ExcalidrawElement,
|
||||
@ -14,147 +127,143 @@ export function renderElement(
|
||||
context: CanvasRenderingContext2D,
|
||||
) {
|
||||
const generator = rc.generator;
|
||||
if (element.type === "selection") {
|
||||
const fillStyle = context.fillStyle;
|
||||
context.fillStyle = "rgba(0, 0, 255, 0.10)";
|
||||
context.fillRect(0, 0, element.width, element.height);
|
||||
context.fillStyle = fillStyle;
|
||||
} else if (element.type === "rectangle") {
|
||||
if (!element.shape) {
|
||||
element.shape = generator.rectangle(0, 0, element.width, element.height, {
|
||||
stroke: element.strokeColor,
|
||||
fill:
|
||||
element.backgroundColor === "transparent"
|
||||
? undefined
|
||||
: element.backgroundColor,
|
||||
fillStyle: element.fillStyle,
|
||||
strokeWidth: element.strokeWidth,
|
||||
roughness: element.roughness,
|
||||
seed: element.seed,
|
||||
});
|
||||
switch (element.type) {
|
||||
case "selection": {
|
||||
const fillStyle = context.fillStyle;
|
||||
context.fillStyle = "rgba(0, 0, 255, 0.10)";
|
||||
context.fillRect(0, 0, element.width, element.height);
|
||||
context.fillStyle = fillStyle;
|
||||
break;
|
||||
}
|
||||
|
||||
context.globalAlpha = element.opacity / 100;
|
||||
rc.draw(element.shape as Drawable);
|
||||
context.globalAlpha = 1;
|
||||
} else if (element.type === "diamond") {
|
||||
if (!element.shape) {
|
||||
const [
|
||||
topX,
|
||||
topY,
|
||||
rightX,
|
||||
rightY,
|
||||
bottomX,
|
||||
bottomY,
|
||||
leftX,
|
||||
leftY,
|
||||
] = getDiamondPoints(element);
|
||||
element.shape = generator.polygon(
|
||||
[
|
||||
[topX, topY],
|
||||
[rightX, rightY],
|
||||
[bottomX, bottomY],
|
||||
[leftX, leftY],
|
||||
],
|
||||
{
|
||||
stroke: element.strokeColor,
|
||||
fill:
|
||||
element.backgroundColor === "transparent"
|
||||
? undefined
|
||||
: element.backgroundColor,
|
||||
fillStyle: element.fillStyle,
|
||||
strokeWidth: element.strokeWidth,
|
||||
roughness: element.roughness,
|
||||
seed: element.seed,
|
||||
},
|
||||
);
|
||||
case "rectangle":
|
||||
case "diamond":
|
||||
case "ellipse":
|
||||
case "line": {
|
||||
generateElement(element, generator);
|
||||
context.globalAlpha = element.opacity / 100;
|
||||
rc.draw(element.shape as Drawable);
|
||||
context.globalAlpha = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
context.globalAlpha = element.opacity / 100;
|
||||
rc.draw(element.shape as Drawable);
|
||||
context.globalAlpha = 1;
|
||||
} else if (element.type === "ellipse") {
|
||||
if (!element.shape) {
|
||||
element.shape = generator.ellipse(
|
||||
element.width / 2,
|
||||
element.height / 2,
|
||||
element.width,
|
||||
element.height,
|
||||
{
|
||||
stroke: element.strokeColor,
|
||||
fill:
|
||||
element.backgroundColor === "transparent"
|
||||
? undefined
|
||||
: element.backgroundColor,
|
||||
fillStyle: element.fillStyle,
|
||||
strokeWidth: element.strokeWidth,
|
||||
roughness: element.roughness,
|
||||
seed: element.seed,
|
||||
curveFitting: 1,
|
||||
},
|
||||
);
|
||||
case "arrow": {
|
||||
generateElement(element, generator);
|
||||
context.globalAlpha = element.opacity / 100;
|
||||
(element.shape as Drawable[]).forEach(shape => rc.draw(shape));
|
||||
context.globalAlpha = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
context.globalAlpha = element.opacity / 100;
|
||||
rc.draw(element.shape as Drawable);
|
||||
context.globalAlpha = 1;
|
||||
} else if (element.type === "arrow") {
|
||||
const [x1, y1, x2, y2, x3, y3, x4, y4] = getArrowPoints(element);
|
||||
const options = {
|
||||
stroke: element.strokeColor,
|
||||
strokeWidth: element.strokeWidth,
|
||||
roughness: element.roughness,
|
||||
seed: element.seed,
|
||||
};
|
||||
|
||||
if (!element.shape) {
|
||||
element.shape = [
|
||||
// \
|
||||
generator.line(x3, y3, x2, y2, options),
|
||||
// -----
|
||||
generator.line(x1, y1, x2, y2, options),
|
||||
// /
|
||||
generator.line(x4, y4, x2, y2, options),
|
||||
];
|
||||
default: {
|
||||
if (isTextElement(element)) {
|
||||
context.globalAlpha = element.opacity / 100;
|
||||
const font = context.font;
|
||||
context.font = element.font;
|
||||
const fillStyle = context.fillStyle;
|
||||
context.fillStyle = element.strokeColor;
|
||||
// Canvas does not support multiline text by default
|
||||
const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
|
||||
const lineHeight = element.height / lines.length;
|
||||
const offset = element.height - element.baseline;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
context.fillText(lines[i], 0, (i + 1) * lineHeight - offset);
|
||||
}
|
||||
context.fillStyle = fillStyle;
|
||||
context.font = font;
|
||||
context.globalAlpha = 1;
|
||||
break;
|
||||
} else {
|
||||
throw new Error("Unimplemented type " + element.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function renderElementToSvg(
|
||||
element: ExcalidrawElement,
|
||||
rsvg: RoughSVG,
|
||||
svgRoot: SVGElement,
|
||||
offsetX?: number,
|
||||
offsetY?: number,
|
||||
) {
|
||||
const generator = rsvg.generator;
|
||||
switch (element.type) {
|
||||
case "selection": {
|
||||
// Since this is used only during editing experience, which is canvas based,
|
||||
// this should not happen
|
||||
throw new Error("Selection rendering is not supported for SVG");
|
||||
}
|
||||
case "rectangle":
|
||||
case "diamond":
|
||||
case "ellipse":
|
||||
case "line": {
|
||||
generateElement(element, generator);
|
||||
const node = rsvg.draw(element.shape as Drawable);
|
||||
const opacity = element.opacity / 100;
|
||||
if (opacity !== 1) {
|
||||
node.setAttribute("stroke-opacity", `${opacity}`);
|
||||
node.setAttribute("fill-opacity", `${opacity}`);
|
||||
}
|
||||
node.setAttribute(
|
||||
"transform",
|
||||
`translate(${offsetX || 0} ${offsetY || 0})`,
|
||||
);
|
||||
svgRoot.appendChild(node);
|
||||
break;
|
||||
}
|
||||
case "arrow": {
|
||||
generateElement(element, generator);
|
||||
const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
|
||||
const opacity = element.opacity / 100;
|
||||
(element.shape as Drawable[]).forEach(shape => {
|
||||
const node = rsvg.draw(shape);
|
||||
if (opacity !== 1) {
|
||||
node.setAttribute("stroke-opacity", `${opacity}`);
|
||||
node.setAttribute("fill-opacity", `${opacity}`);
|
||||
}
|
||||
node.setAttribute(
|
||||
"transform",
|
||||
`translate(${offsetX || 0} ${offsetY || 0})`,
|
||||
);
|
||||
group.appendChild(node);
|
||||
});
|
||||
svgRoot.appendChild(group);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
if (isTextElement(element)) {
|
||||
const opacity = element.opacity / 100;
|
||||
const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
|
||||
if (opacity !== 1) {
|
||||
node.setAttribute("stroke-opacity", `${opacity}`);
|
||||
node.setAttribute("fill-opacity", `${opacity}`);
|
||||
}
|
||||
node.setAttribute(
|
||||
"transform",
|
||||
`translate(${offsetX || 0} ${offsetY || 0})`,
|
||||
);
|
||||
const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
|
||||
const lineHeight = element.height / lines.length;
|
||||
const offset = element.height - element.baseline;
|
||||
const fontSplit = element.font.split(" ").filter(d => !!d.trim());
|
||||
let fontFamily = fontSplit[0];
|
||||
let fontSize = "20px";
|
||||
if (fontSplit.length > 1) {
|
||||
fontFamily = fontSplit[1];
|
||||
fontSize = fontSplit[0];
|
||||
}
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text");
|
||||
text.textContent = lines[i];
|
||||
text.setAttribute("x", "0");
|
||||
text.setAttribute("y", `${(i + 1) * lineHeight - offset}`);
|
||||
text.setAttribute("font-family", fontFamily);
|
||||
text.setAttribute("font-size", fontSize);
|
||||
text.setAttribute("fill", element.strokeColor);
|
||||
node.appendChild(text);
|
||||
}
|
||||
svgRoot.appendChild(node);
|
||||
} else {
|
||||
throw new Error("Unimplemented type " + element.type);
|
||||
}
|
||||
}
|
||||
|
||||
context.globalAlpha = element.opacity / 100;
|
||||
(element.shape as Drawable[]).forEach(shape => rc.draw(shape));
|
||||
context.globalAlpha = 1;
|
||||
return;
|
||||
} else if (element.type === "line") {
|
||||
const [x1, y1, x2, y2] = getLinePoints(element);
|
||||
const options = {
|
||||
stroke: element.strokeColor,
|
||||
strokeWidth: element.strokeWidth,
|
||||
roughness: element.roughness,
|
||||
seed: element.seed,
|
||||
};
|
||||
|
||||
if (!element.shape) {
|
||||
element.shape = generator.line(x1, y1, x2, y2, options);
|
||||
}
|
||||
|
||||
context.globalAlpha = element.opacity / 100;
|
||||
rc.draw(element.shape as Drawable);
|
||||
context.globalAlpha = 1;
|
||||
} else if (isTextElement(element)) {
|
||||
context.globalAlpha = element.opacity / 100;
|
||||
const font = context.font;
|
||||
context.font = element.font;
|
||||
const fillStyle = context.fillStyle;
|
||||
context.fillStyle = element.strokeColor;
|
||||
// Canvas does not support multiline text by default
|
||||
const lines = element.text.replace(/\r\n?/g, "\n").split("\n");
|
||||
const lineHeight = element.height / lines.length;
|
||||
const offset = element.height - element.baseline;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
context.fillText(lines[i], 0, (i + 1) * lineHeight - offset);
|
||||
}
|
||||
context.fillStyle = fillStyle;
|
||||
context.font = font;
|
||||
context.globalAlpha = 1;
|
||||
} else {
|
||||
throw new Error("Unimplemented type " + element.type);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||
import { RoughSVG } from "roughjs/bin/svg";
|
||||
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { getElementAbsoluteCoords, handlerRectangles } from "../element";
|
||||
@ -11,7 +12,7 @@ import {
|
||||
SCROLLBAR_WIDTH,
|
||||
} from "../scene/scrollbars";
|
||||
|
||||
import { renderElement } from "./renderElement";
|
||||
import { renderElement, renderElementToSvg } from "./renderElement";
|
||||
|
||||
export function renderScene(
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@ -154,3 +155,31 @@ function isVisibleElement(
|
||||
y2 += scrollY;
|
||||
return x2 >= 0 && x1 <= canvasWidth && y2 >= 0 && y1 <= canvasHeight;
|
||||
}
|
||||
|
||||
// This should be only called for exporting purposes
|
||||
export function renderSceneToSvg(
|
||||
elements: readonly ExcalidrawElement[],
|
||||
rsvg: RoughSVG,
|
||||
svgRoot: SVGElement,
|
||||
{
|
||||
offsetX = 0,
|
||||
offsetY = 0,
|
||||
}: {
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
} = {},
|
||||
) {
|
||||
if (!svgRoot) {
|
||||
return;
|
||||
}
|
||||
// render elements
|
||||
elements.forEach(element => {
|
||||
renderElementToSvg(
|
||||
element,
|
||||
rsvg,
|
||||
svgRoot,
|
||||
element.x + offsetX,
|
||||
element.y + offsetY,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import { getDefaultAppState } from "../appState";
|
||||
|
||||
import { AppState } from "../types";
|
||||
import { ExportType } from "./types";
|
||||
import { getExportCanvasPreview } from "./getExportCanvasPreview";
|
||||
import { exportToCanvas, exportToSvg } from "./export";
|
||||
import nanoid from "nanoid";
|
||||
import { fileOpen, fileSave } from "browser-nativefs";
|
||||
import { getCommonBounds } from "../element";
|
||||
@ -194,7 +194,19 @@ export async function exportCanvas(
|
||||
return window.alert(i18n.t("alerts.cannotExportEmptyCanvas"));
|
||||
// calculate smallest area to fit the contents in
|
||||
|
||||
const tempCanvas = getExportCanvasPreview(elements, {
|
||||
if (type === "svg") {
|
||||
const tempSvg = exportToSvg(elements, {
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
exportPadding,
|
||||
});
|
||||
await fileSave(new Blob([tempSvg.outerHTML], { type: "image/svg+xml" }), {
|
||||
fileName: `${name}.svg`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const tempCanvas = exportToCanvas(elements, {
|
||||
exportBackground,
|
||||
viewBackgroundColor,
|
||||
exportPadding,
|
||||
|
112
src/scene/export.ts
Normal file
112
src/scene/export.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import rough from "roughjs/bin/rough";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { getCommonBounds } from "../element/bounds";
|
||||
import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
|
||||
import { distance, SVG_NS } from "../utils";
|
||||
|
||||
export function exportToCanvas(
|
||||
elements: readonly ExcalidrawElement[],
|
||||
{
|
||||
exportBackground,
|
||||
exportPadding = 10,
|
||||
viewBackgroundColor,
|
||||
scale = 1,
|
||||
}: {
|
||||
exportBackground: boolean;
|
||||
exportPadding?: number;
|
||||
scale?: number;
|
||||
viewBackgroundColor: string;
|
||||
},
|
||||
createCanvas: (width: number, height: number) => any = function(
|
||||
width,
|
||||
height,
|
||||
) {
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
tempCanvas.width = width * scale;
|
||||
tempCanvas.height = height * scale;
|
||||
return tempCanvas;
|
||||
},
|
||||
) {
|
||||
// calculate smallest area to fit the contents in
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
const width = distance(minX, maxX) + exportPadding * 2;
|
||||
const height = distance(minY, maxY) + exportPadding * 2;
|
||||
|
||||
const tempCanvas: any = createCanvas(width, height);
|
||||
tempCanvas.getContext("2d")?.scale(scale, scale);
|
||||
|
||||
renderScene(
|
||||
elements,
|
||||
rough.canvas(tempCanvas),
|
||||
tempCanvas,
|
||||
{
|
||||
viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
|
||||
scrollX: 0,
|
||||
scrollY: 0,
|
||||
},
|
||||
{
|
||||
offsetX: -minX + exportPadding,
|
||||
offsetY: -minY + exportPadding,
|
||||
renderScrollbars: false,
|
||||
renderSelection: false,
|
||||
},
|
||||
);
|
||||
return tempCanvas;
|
||||
}
|
||||
|
||||
export function exportToSvg(
|
||||
elements: readonly ExcalidrawElement[],
|
||||
{
|
||||
exportBackground,
|
||||
exportPadding = 10,
|
||||
viewBackgroundColor,
|
||||
}: {
|
||||
exportBackground: boolean;
|
||||
exportPadding?: number;
|
||||
viewBackgroundColor: string;
|
||||
},
|
||||
): SVGSVGElement {
|
||||
// calculate canvas dimensions
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
const width = distance(minX, maxX) + exportPadding * 2;
|
||||
const height = distance(minY, maxY) + exportPadding * 2;
|
||||
|
||||
// initialze SVG root
|
||||
const svgRoot = document.createElementNS(SVG_NS, "svg");
|
||||
svgRoot.setAttribute("version", "1.1");
|
||||
svgRoot.setAttribute("xmlns", SVG_NS);
|
||||
svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||
|
||||
svgRoot.innerHTML = `
|
||||
<defs>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Virgil";
|
||||
src: url("https://excalidraw.com/FG_Virgil.ttf");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Cascadia";
|
||||
src: url("https://excalidraw.com/Cascadia.ttf");
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
`;
|
||||
|
||||
// render backgroiund rect
|
||||
if (exportBackground && viewBackgroundColor) {
|
||||
const rect = svgRoot.ownerDocument!.createElementNS(SVG_NS, "rect");
|
||||
rect.setAttribute("x", "0");
|
||||
rect.setAttribute("y", "0");
|
||||
rect.setAttribute("width", `${width}`);
|
||||
rect.setAttribute("height", `${height}`);
|
||||
rect.setAttribute("fill", viewBackgroundColor);
|
||||
svgRoot.appendChild(rect);
|
||||
}
|
||||
|
||||
const rsvg = rough.svg(svgRoot);
|
||||
renderSceneToSvg(elements, rsvg, svgRoot, {
|
||||
offsetX: -minX + exportPadding,
|
||||
offsetY: -minY + exportPadding,
|
||||
});
|
||||
return svgRoot;
|
||||
}
|
@ -1,55 +0,0 @@
|
||||
import rough from "roughjs/bin/rough";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { getCommonBounds } from "../element/bounds";
|
||||
import { renderScene } from "../renderer/renderScene";
|
||||
import { distance } from "../utils";
|
||||
|
||||
export function getExportCanvasPreview(
|
||||
elements: readonly ExcalidrawElement[],
|
||||
{
|
||||
exportBackground,
|
||||
exportPadding = 10,
|
||||
viewBackgroundColor,
|
||||
scale = 1,
|
||||
}: {
|
||||
exportBackground: boolean;
|
||||
exportPadding?: number;
|
||||
scale?: number;
|
||||
viewBackgroundColor: string;
|
||||
},
|
||||
createCanvas: (width: number, height: number) => any = function(
|
||||
width,
|
||||
height,
|
||||
) {
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
tempCanvas.width = width * scale;
|
||||
tempCanvas.height = height * scale;
|
||||
return tempCanvas;
|
||||
},
|
||||
) {
|
||||
// calculate smallest area to fit the contents in
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
const width = distance(minX, maxX) + exportPadding * 2;
|
||||
const height = distance(minY, maxY) + exportPadding * 2;
|
||||
|
||||
const tempCanvas: any = createCanvas(width, height);
|
||||
tempCanvas.getContext("2d")?.scale(scale, scale);
|
||||
|
||||
renderScene(
|
||||
elements,
|
||||
rough.canvas(tempCanvas),
|
||||
tempCanvas,
|
||||
{
|
||||
viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
|
||||
scrollX: 0,
|
||||
scrollY: 0,
|
||||
},
|
||||
{
|
||||
offsetX: -minX + exportPadding,
|
||||
offsetY: -minY + exportPadding,
|
||||
renderScrollbars: false,
|
||||
renderSelection: false,
|
||||
},
|
||||
);
|
||||
return tempCanvas;
|
||||
}
|
@ -16,4 +16,4 @@ export interface Scene {
|
||||
elements: ExcalidrawTextElement[];
|
||||
}
|
||||
|
||||
export type ExportType = "png" | "clipboard" | "backend";
|
||||
export type ExportType = "png" | "clipboard" | "backend" | "svg";
|
||||
|
@ -1,3 +1,5 @@
|
||||
export const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
export function getDateTime() {
|
||||
const date = new Date();
|
||||
const year = date.getFullYear();
|
||||
|
Loading…
x
Reference in New Issue
Block a user