excalidraw/src/scene/fancyBackground.ts

465 lines
12 KiB
TypeScript

import {
DEFAULT_EXPORT_PADDING,
EXPORT_LOGO_URL,
EXPORT_LOGO_URL_DARK,
FANCY_BACKGROUND_IMAGES,
FANCY_BG_BORDER_RADIUS,
FANCY_BG_LOGO_BOTTOM_PADDING,
FANCY_BG_LOGO_PADDING,
FANCY_BG_PADDING,
IMAGE_INVERT_FILTER,
SVG_NS,
THEME,
THEME_FILTER,
} from "../constants";
import { loadHTMLImageElement, loadSVGElement } from "../element/image";
import { getScaleToFill } from "../packages/utils";
import { roundRect } from "../renderer/roundRect";
import { AppState, DataURL, Dimensions, ExportPadding } from "../types";
export const getFancyBackgroundPadding = (
exportPadding: ExportPadding = [
DEFAULT_EXPORT_PADDING,
DEFAULT_EXPORT_PADDING,
DEFAULT_EXPORT_PADDING,
DEFAULT_EXPORT_PADDING,
],
includeLogo = false,
): ExportPadding =>
exportPadding.map(
(padding, index) =>
FANCY_BG_PADDING +
FANCY_BG_BORDER_RADIUS +
padding +
(index === 2 && includeLogo ? 20 : 0),
) as [number, number, number, number];
const addImageBackground = (
context: CanvasRenderingContext2D,
normalizedCanvasDimensions: Dimensions,
fancyBackgroundImage: HTMLImageElement,
exportScale: AppState["exportScale"],
) => {
context.save();
context.beginPath();
if (context.roundRect) {
context.roundRect(
0,
0,
normalizedCanvasDimensions.width,
normalizedCanvasDimensions.height,
FANCY_BG_BORDER_RADIUS * exportScale,
);
} else {
roundRect(
context,
0,
0,
normalizedCanvasDimensions.width,
normalizedCanvasDimensions.height,
FANCY_BG_BORDER_RADIUS * exportScale,
);
}
const scale = getScaleToFill(
{ width: fancyBackgroundImage.width, height: fancyBackgroundImage.height },
{
width: normalizedCanvasDimensions.width,
height: normalizedCanvasDimensions.height,
},
);
const x =
(normalizedCanvasDimensions.width - fancyBackgroundImage.width * scale) / 2;
const y =
(normalizedCanvasDimensions.height - fancyBackgroundImage.height * scale) /
2;
context.clip();
context.drawImage(
fancyBackgroundImage,
x,
y,
fancyBackgroundImage.width * scale,
fancyBackgroundImage.height * scale,
);
context.closePath();
context.restore();
};
const getContentBackgound = (
contentSize: Dimensions,
normalizedCanvasDimensions: Dimensions,
exportScale: number,
includeLogo: boolean,
): { x: number; y: number; width: number; height: number } => {
const x =
(normalizedCanvasDimensions.width - contentSize.width * exportScale) / 2 -
FANCY_BG_PADDING * exportScale;
const y =
(normalizedCanvasDimensions.height -
(contentSize.height + DEFAULT_EXPORT_PADDING + FANCY_BG_BORDER_RADIUS) *
exportScale) /
2 -
FANCY_BG_PADDING * exportScale;
const width =
(contentSize.width +
(DEFAULT_EXPORT_PADDING + FANCY_BG_BORDER_RADIUS) * 2) *
exportScale;
const height =
(contentSize.height +
DEFAULT_EXPORT_PADDING +
FANCY_BG_BORDER_RADIUS -
(includeLogo ? FANCY_BG_LOGO_PADDING : 0) +
(DEFAULT_EXPORT_PADDING + FANCY_BG_BORDER_RADIUS) * 2) *
exportScale;
return { x, y, width, height };
};
const addContentBackground = (
context: CanvasRenderingContext2D,
normalizedCanvasDimensions: Dimensions,
contentBackgroundColor: string,
exportScale: AppState["exportScale"],
theme: AppState["theme"],
contentSize: Dimensions,
includeLogo: boolean,
) => {
const shadows = [
{
offsetX: 0,
offsetY: 0,
blur: 2,
alpha: 0.02,
},
{
offsetX: 0,
offsetY: 1,
blur: 4,
alpha: 0.04,
},
{
offsetX: 0,
offsetY: 4,
blur: 10,
alpha: 0.05,
},
{ offsetX: 0, offsetY: 13, blur: 33, alpha: 0.07 },
];
shadows.forEach((shadow, index): void => {
context.save();
context.beginPath();
context.shadowColor = `rgba(0, 0, 0, ${shadow.alpha})`;
context.shadowBlur = shadow.blur * exportScale;
context.shadowOffsetX = shadow.offsetX * exportScale;
context.shadowOffsetY = shadow.offsetY * exportScale;
const { x, y, width, height } = getContentBackgound(
contentSize,
normalizedCanvasDimensions,
exportScale,
includeLogo,
);
if (context.roundRect) {
context.roundRect(
x,
y,
width,
height,
FANCY_BG_BORDER_RADIUS * exportScale,
);
} else {
roundRect(
context,
x,
y,
width,
height,
FANCY_BG_BORDER_RADIUS * exportScale,
);
}
if (index === shadows.length - 1) {
if (theme === THEME.DARK) {
context.filter = THEME_FILTER;
}
context.fillStyle = contentBackgroundColor;
context.fill();
}
context.closePath();
context.restore();
});
};
const addLogo = (
context: CanvasRenderingContext2D,
normalizedCanvasDimensions: Dimensions,
logoImage: HTMLImageElement,
exportScale: number,
) => {
context.save();
context.beginPath();
context.drawImage(
logoImage,
(normalizedCanvasDimensions.width - logoImage.width * exportScale) / 2,
normalizedCanvasDimensions.height -
(logoImage.height + FANCY_BG_LOGO_BOTTOM_PADDING) * exportScale,
logoImage.width * exportScale,
logoImage.height * exportScale,
);
context.closePath();
context.restore();
};
export const applyFancyBackgroundOnCanvas = async ({
canvas,
fancyBackgroundImageKey,
backgroundColor,
exportScale,
theme,
contentSize,
includeLogo,
}: {
canvas: HTMLCanvasElement;
fancyBackgroundImageKey: Exclude<
keyof typeof FANCY_BACKGROUND_IMAGES,
"solid"
>;
backgroundColor: string;
exportScale: AppState["exportScale"];
theme: AppState["theme"];
contentSize: Dimensions;
includeLogo: boolean;
}) => {
const context = canvas.getContext("2d")!;
const fancyBackgroundImageUrl =
FANCY_BACKGROUND_IMAGES[fancyBackgroundImageKey][theme];
const fancyBackgroundImage = await loadHTMLImageElement(
fancyBackgroundImageUrl,
);
const normalizedCanvasDimensions: Dimensions = {
width: canvas.width,
height: canvas.height,
};
addImageBackground(
context,
normalizedCanvasDimensions,
fancyBackgroundImage,
exportScale,
);
addContentBackground(
context,
normalizedCanvasDimensions,
backgroundColor,
exportScale,
theme,
contentSize,
includeLogo,
);
if (includeLogo) {
const logoImage = await loadHTMLImageElement(
theme === THEME.DARK ? EXPORT_LOGO_URL_DARK : EXPORT_LOGO_URL,
);
addLogo(context, normalizedCanvasDimensions, logoImage, exportScale);
}
};
const addImageBackgroundToSvg = async ({
svgRoot,
fancyBackgroundImageUrl,
dimensions,
theme,
}: {
svgRoot: SVGSVGElement;
fancyBackgroundImageUrl: DataURL;
dimensions: Dimensions;
theme: AppState["theme"];
}) => {
const fancyBackgroundImage = await loadSVGElement(fancyBackgroundImageUrl);
fancyBackgroundImage.setAttribute("x", "0");
fancyBackgroundImage.setAttribute("y", "0");
fancyBackgroundImage.setAttribute("width", `${dimensions.width}`);
fancyBackgroundImage.setAttribute("height", `${dimensions.height}`);
fancyBackgroundImage.setAttribute("preserveAspectRatio", "xMidYMid slice");
if (theme === THEME.DARK) {
fancyBackgroundImage.setAttribute("filter", IMAGE_INVERT_FILTER);
}
svgRoot.appendChild(fancyBackgroundImage);
};
const addContentBackgroundToSvg = ({
svgRoot,
contentSize,
backgroundColor,
dimensions,
includeLogo,
}: {
svgRoot: SVGSVGElement;
contentSize: Dimensions;
backgroundColor: string;
dimensions: Dimensions;
includeLogo: boolean;
}) => {
// Create the shadow filter
const filter = svgRoot.ownerDocument!.createElementNS(SVG_NS, "filter");
filter.setAttribute("id", "shadow");
const feGaussianBlur = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"feGaussianBlur",
);
feGaussianBlur.setAttribute("in", "SourceAlpha");
feGaussianBlur.setAttribute("stdDeviation", "3");
const feOffset = svgRoot.ownerDocument!.createElementNS(SVG_NS, "feOffset");
feOffset.setAttribute("dx", "4");
feOffset.setAttribute("dy", "4");
feOffset.setAttribute("result", "offsetblur");
const feFlood = svgRoot.ownerDocument!.createElementNS(SVG_NS, "feFlood");
feFlood.setAttribute("flood-color", "black");
feFlood.setAttribute("flood-opacity", "0.04");
feFlood.setAttribute("result", "color");
const feComposite = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"feComposite",
);
feComposite.setAttribute("in", "color");
feComposite.setAttribute("in2", "offsetblur");
feComposite.setAttribute("operator", "in");
feComposite.setAttribute("result", "shadow");
const feMerge = svgRoot.ownerDocument!.createElementNS(SVG_NS, "feMerge");
const feMergeNodeIn = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"feMergeNode",
);
feMergeNodeIn.setAttribute("in", "shadow");
const feMergeNodeGraphic = svgRoot.ownerDocument!.createElementNS(
SVG_NS,
"feMergeNode",
);
feMergeNodeGraphic.setAttribute("in", "SourceGraphic");
feMerge.appendChild(feMergeNodeIn);
feMerge.appendChild(feMergeNodeGraphic);
filter.appendChild(feGaussianBlur);
filter.appendChild(feOffset);
filter.appendChild(feFlood);
filter.appendChild(feComposite);
filter.appendChild(feMerge);
svgRoot.appendChild(filter);
// Solid color background
const { x, y, width, height } = getContentBackgound(
contentSize,
dimensions,
1, // svg is scaled on root
includeLogo,
);
const rect = svgRoot.ownerDocument!.createElementNS(SVG_NS, "rect");
rect.setAttribute("x", x.toString());
rect.setAttribute("y", y.toString());
rect.setAttribute("width", width.toString());
rect.setAttribute("height", height.toString());
rect.setAttribute("rx", FANCY_BG_BORDER_RADIUS.toString());
rect.setAttribute("ry", FANCY_BG_BORDER_RADIUS.toString());
rect.setAttribute("fill", backgroundColor);
rect.setAttribute("filter", "url(#shadow)");
svgRoot.appendChild(rect);
};
const addLogoToSvg = (
svgRoot: SVGSVGElement,
normalizedCanvasDimensions: Dimensions,
logoImage: SVGSVGElement,
theme: AppState["theme"],
) => {
const logoWidth = parseFloat(logoImage.getAttribute("width") || "0");
const logoHeight = parseFloat(logoImage.getAttribute("height") || "0");
const x = (normalizedCanvasDimensions.width - logoWidth) / 2;
const y =
normalizedCanvasDimensions.height -
(logoHeight + FANCY_BG_LOGO_BOTTOM_PADDING);
logoImage.setAttribute("x", `${x}`);
logoImage.setAttribute("y", `${y}`);
if (theme === THEME.DARK) {
logoImage.setAttribute("filter", IMAGE_INVERT_FILTER);
}
svgRoot.appendChild(logoImage);
};
export const applyFancyBackgroundOnSvg = async ({
svgRoot,
fancyBackgroundImageKey,
backgroundColor,
canvasDimensions,
theme,
contentSize,
includeLogo,
}: {
svgRoot: SVGSVGElement;
fancyBackgroundImageKey: Exclude<
keyof typeof FANCY_BACKGROUND_IMAGES,
"solid"
>;
backgroundColor: string;
canvasDimensions: Dimensions;
theme: AppState["theme"];
contentSize: Dimensions;
includeLogo: boolean;
}) => {
// Image background
const fancyBackgroundImageUrl =
FANCY_BACKGROUND_IMAGES[fancyBackgroundImageKey][theme];
await addImageBackgroundToSvg({
svgRoot,
fancyBackgroundImageUrl,
dimensions: canvasDimensions,
theme,
});
addContentBackgroundToSvg({
svgRoot,
contentSize,
backgroundColor,
dimensions: {
width: canvasDimensions.width,
height: canvasDimensions.height,
},
includeLogo,
});
if (includeLogo) {
const logoImage = await loadSVGElement(
theme === THEME.DARK ? EXPORT_LOGO_URL_DARK : EXPORT_LOGO_URL,
);
addLogoToSvg(
svgRoot,
{
width: canvasDimensions.width,
height: canvasDimensions.height,
},
logoImage,
theme,
);
}
};