diff --git a/src/appState.ts b/src/appState.ts
index b51b430e0..a1f8835e6 100644
--- a/src/appState.ts
+++ b/src/appState.ts
@@ -72,7 +72,7 @@ export const getDefaultAppState = (): Omit<
openMenu: null,
openPopup: null,
openSidebar: null,
- openDialog: null,
+ openDialog: "imageExport",
pasteDialog: { shown: false, data: null },
previousSelectedElementIds: {},
resizingElement: null,
@@ -101,7 +101,7 @@ export const getDefaultAppState = (): Omit<
pendingImageElementId: null,
showHyperlinkPopup: false,
selectedLinearElement: null,
- exportBackgroundImage:
+ fancyBackgroundImageUrl:
EXPORT_BACKGROUND_IMAGES[DEFAULT_EXPORT_BACKGROUND_IMAGE].path,
};
};
diff --git a/src/components/ImageExportDialog.tsx b/src/components/ImageExportDialog.tsx
index 10013172b..dc6ced64a 100644
--- a/src/components/ImageExportDialog.tsx
+++ b/src/components/ImageExportDialog.tsx
@@ -138,7 +138,7 @@ const ImageExportModal = ({
}, [
appState,
appState.exportBackground,
- appState.exportBackgroundImage,
+ appState.fancyBackgroundImageUrl,
files,
exportedElements,
]);
@@ -150,7 +150,7 @@ const ImageExportModal = ({
@@ -159,8 +159,8 @@ const ImageExportModal = ({
{t("imageExportDialog.header")}
-
- {!nativeFileSystemSupported && (
+ {!nativeFileSystemSupported && (
+
- )}
-
+
+ )}
{someElementIsSelected && (
{
- const scale = Math.max(
- containerSize.w / contentSize.w,
- containerSize.h / contentSize.h,
- );
-
- return scale;
-};
-
-const getScaleToFit = (contentSize: Dimensions, containerSize: Dimensions) => {
- const scale = Math.min(
- containerSize.w / contentSize.w,
- containerSize.h / contentSize.h,
- );
-
- return scale;
-};
-
-const addExportBackground = (
- context: CanvasRenderingContext2D,
- normalizedCanvasWidth: number,
- normalizedCanvasHeight: number,
- svgUrl: string,
- rectangleColor: string,
-): Promise => {
- return new Promise((resolve, reject) => {
- // Create a new image object
- const img = new Image();
-
- // When the image has loaded
- img.onload = (): void => {
- // Scale image to fill canvas and draw it onto the canvas
- context.save();
- context.beginPath();
- if (context.roundRect) {
- context.roundRect(
- 0,
- 0,
- normalizedCanvasWidth,
- normalizedCanvasHeight,
- EXPORT_BG_BORDER_RADIUS,
- );
- } else {
- roundRect(
- context,
- 0,
- 0,
- normalizedCanvasWidth,
- normalizedCanvasHeight,
- EXPORT_BG_BORDER_RADIUS,
- );
- }
- const scale = getScaleToFill(
- { w: img.width, h: img.height },
- { w: normalizedCanvasWidth, h: normalizedCanvasHeight },
- );
- const x = (normalizedCanvasWidth - img.width * scale) / 2;
- const y = (normalizedCanvasHeight - img.height * scale) / 2;
- context.clip();
- context.drawImage(img, x, y, img.width * scale, img.height * scale);
- context.closePath();
- context.restore();
-
- // Create shadow similar to the CSS box-shadow
- const shadows = [
- {
- offsetX: 0,
- offsetY: 0.7698959708213806,
- blur: 1.4945039749145508,
- alpha: 0.02,
- },
- {
- offsetX: 0,
- offsetY: 1.1299999952316284,
- blur: 4.1321120262146,
- alpha: 0.04,
- },
- {
- offsetX: 0,
- offsetY: 4.130000114440918,
- blur: 9.94853401184082,
- 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;
- context.shadowOffsetX = shadow.offsetX;
- context.shadowOffsetY = shadow.offsetY;
-
- if (context.roundRect) {
- context.roundRect(
- EXPORT_BG_PADDING,
- EXPORT_BG_PADDING,
- normalizedCanvasWidth - EXPORT_BG_PADDING * 2,
- normalizedCanvasHeight - EXPORT_BG_PADDING * 2,
- EXPORT_BG_BORDER_RADIUS,
- );
- } else {
- roundRect(
- context,
- EXPORT_BG_PADDING,
- EXPORT_BG_PADDING,
- normalizedCanvasWidth - EXPORT_BG_PADDING * 2,
- normalizedCanvasHeight - EXPORT_BG_PADDING * 2,
- EXPORT_BG_BORDER_RADIUS,
- );
- }
-
- if (index === shadows.length - 1) {
- context.fillStyle = rectangleColor;
- context.fill();
- }
- context.closePath();
- context.restore();
- });
-
- // Reset shadow properties for future drawings
- context.shadowColor = "transparent";
- context.shadowBlur = 0;
- context.shadowOffsetX = 0;
- context.shadowOffsetY = 0;
-
- resolve();
- };
-
- img.onerror = (): void => {
- reject(new Error(`Failed to load image with URL ${svgUrl}`));
- };
-
- // Start loading the image
- img.src = svgUrl;
- });
-};
-
-export const paintBackground = async (
- context: CanvasRenderingContext2D,
- normalizedCanvasWidth: number,
- normalizedCanvasHeight: number,
- {
- viewBackgroundColor,
- isExporting,
- exportBackgroundImage,
- }: Pick<
- RenderConfig,
- "viewBackgroundColor" | "isExporting" | "exportBackgroundImage"
- >,
-): Promise => {
- if (typeof viewBackgroundColor === "string") {
- const hasTransparence =
- viewBackgroundColor === "transparent" ||
- viewBackgroundColor.length === 5 || // #RGBA
- viewBackgroundColor.length === 9 || // #RRGGBBA
- /(hsla|rgba)\(/.test(viewBackgroundColor);
- if (hasTransparence) {
- context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
- }
- context.save();
- if (isExporting && exportBackgroundImage) {
- try {
- await addExportBackground(
- context,
- normalizedCanvasWidth,
- normalizedCanvasHeight,
- exportBackgroundImage,
- viewBackgroundColor,
- );
- } catch (error) {
- console.error("Failed to add background:", error);
- }
- } else {
- context.fillStyle = viewBackgroundColor;
- context.fillRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
- }
-
- context.restore();
- } else {
- context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
- }
-};
-
-export const _renderScene = async ({
+export const _renderScene = ({
elements,
appState,
scale,
@@ -592,24 +399,22 @@ export const _renderScene = async ({
rc: RoughCanvas;
canvas: HTMLCanvasElement;
renderConfig: RenderConfig;
-}): Promise<{
- atLeastOneVisibleElement: boolean;
- scrollBars: ScrollBars | undefined;
-}> =>
+}) =>
// extra options passed to the renderer
{
if (canvas === null) {
- return { atLeastOneVisibleElement: false, scrollBars: undefined };
+ return { atLeastOneVisibleElement: false };
}
const {
renderScrollbars = false,
renderSelection = true,
renderGrid = true,
isExporting,
- viewBackgroundColor,
- exportBackgroundImage,
} = renderConfig;
+ const preserveCanvasContent =
+ isExporting && appState.fancyBackgroundImageUrl;
+
const selectionColor = renderConfig.selectionColor || oc.black;
const context = canvas.getContext("2d")!;
@@ -625,16 +430,24 @@ export const _renderScene = async ({
context.filter = THEME_FILTER;
}
- await paintBackground(
- context,
- normalizedCanvasWidth,
- normalizedCanvasHeight,
- {
- isExporting,
- viewBackgroundColor,
- exportBackgroundImage,
- },
- );
+ // Paint background
+ if (typeof renderConfig.viewBackgroundColor === "string") {
+ const hasTransparence =
+ renderConfig.viewBackgroundColor === "transparent" ||
+ renderConfig.viewBackgroundColor.length === 5 || // #RGBA
+ renderConfig.viewBackgroundColor.length === 9 || // #RRGGBBA
+ /(hsla|rgba)\(/.test(renderConfig.viewBackgroundColor);
+ if (hasTransparence && !preserveCanvasContent) {
+ context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
+ }
+ context.save();
+ context.fillStyle = renderConfig.viewBackgroundColor;
+ context.fillRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
+ context.restore();
+ } else if (!preserveCanvasContent) {
+ context.clearRect(0, 0, normalizedCanvasWidth, normalizedCanvasHeight);
+ }
+
// Apply zoom
context.save();
context.scale(renderConfig.zoom.value, renderConfig.zoom.value);
@@ -663,28 +476,6 @@ export const _renderScene = async ({
}),
);
- if (isExporting && exportBackgroundImage) {
- context.save();
-
- const contentAreaSize: Dimensions = {
- w: canvas.width - (EXPORT_BG_PADDING + EXPORT_BG_BORDER_RADIUS) * 2,
- h: canvas.height - (EXPORT_BG_PADDING + EXPORT_BG_BORDER_RADIUS) * 2,
- };
-
- const scale = getScaleToFit(
- {
- w: canvas.width,
- h: canvas.height,
- },
- contentAreaSize,
- );
- context.translate(
- EXPORT_BG_PADDING + EXPORT_BG_BORDER_RADIUS,
- EXPORT_BG_PADDING + EXPORT_BG_BORDER_RADIUS,
- );
- context.scale(scale, scale);
- }
-
const groupsToBeAddedToFrame = new Set();
visibleElements.forEach((element) => {
@@ -1206,31 +997,27 @@ export const _renderScene = async ({
}
context.restore();
-
- return {
- atLeastOneVisibleElement: visibleElements.length > 0,
- scrollBars,
- };
+ return { atLeastOneVisibleElement: visibleElements.length > 0, scrollBars };
};
const renderSceneThrottled = throttleRAF(
- async (config: {
+ (config: {
elements: readonly NonDeletedExcalidrawElement[];
appState: AppState;
scale: number;
rc: RoughCanvas;
canvas: HTMLCanvasElement;
renderConfig: RenderConfig;
- callback?: (data: Unpromisify>) => void;
+ callback?: (data: ReturnType) => void;
}) => {
- const ret = await _renderScene(config);
+ const ret = _renderScene(config);
config.callback?.(ret);
},
{ trailing: true },
);
/** renderScene throttled to animation framerate */
-export const renderScene = async (
+export const renderScene = (
config: {
elements: readonly NonDeletedExcalidrawElement[];
appState: AppState;
@@ -1238,25 +1025,19 @@ export const renderScene = async (
rc: RoughCanvas;
canvas: HTMLCanvasElement;
renderConfig: RenderConfig;
- callback?: (data: Unpromisify>) => void;
+ callback?: (data: ReturnType) => void;
},
/** Whether to throttle rendering. Defaults to false.
* When throttling, no value is returned. Use the callback instead. */
throttle?: T,
-): Promise<
- T extends true ? void : Unpromisify>
-> => {
+): T extends true ? void : ReturnType => {
if (throttle) {
renderSceneThrottled(config);
- return undefined as T extends true
- ? void
- : Unpromisify>;
+ return undefined as T extends true ? void : ReturnType;
}
- const ret = await _renderScene(config);
+ const ret = _renderScene(config);
config.callback?.(ret);
- return ret as T extends true
- ? void
- : Unpromisify>;
+ return ret as T extends true ? void : ReturnType;
};
const renderTransformHandles = (
diff --git a/src/scene/export.ts b/src/scene/export.ts
index 28da567ec..90562c2f7 100644
--- a/src/scene/export.ts
+++ b/src/scene/export.ts
@@ -12,6 +12,7 @@ import {
updateImageCache,
} from "../element/image";
import Scene from "./Scene";
+import { applyFancyBackground } from "./fancyBackground";
export const SVG_EXPORT_TAG = ``;
@@ -54,14 +55,25 @@ export const exportToCanvas = async (
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
- await renderScene({
+ if (appState.fancyBackgroundImageUrl) {
+ await applyFancyBackground(
+ canvas,
+ appState.fancyBackgroundImageUrl,
+ viewBackgroundColor,
+ );
+ }
+
+ renderScene({
elements,
appState,
scale,
rc: rough.canvas(canvas),
canvas,
renderConfig: {
- viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
+ viewBackgroundColor:
+ exportBackground && !appState.fancyBackgroundImageUrl
+ ? viewBackgroundColor
+ : null,
scrollX: -minX + (onlyExportingSingleFrame ? 0 : exportPadding),
scrollY: -minY + (onlyExportingSingleFrame ? 0 : exportPadding),
zoom: defaultAppState.zoom,
@@ -76,7 +88,7 @@ export const exportToCanvas = async (
renderSelection: false,
renderGrid: false,
isExporting: true,
- exportBackgroundImage: appState.exportBackgroundImage,
+ exportBackgroundImage: appState.fancyBackgroundImageUrl,
},
});
diff --git a/src/scene/fancyBackground.ts b/src/scene/fancyBackground.ts
new file mode 100644
index 000000000..44f1c6e6e
--- /dev/null
+++ b/src/scene/fancyBackground.ts
@@ -0,0 +1,147 @@
+import { EXPORT_BG_BORDER_RADIUS, EXPORT_BG_PADDING } from "../constants";
+import { loadHTMLImageElement } from "../element/image";
+import { roundRect } from "../renderer/roundRect";
+import { DataURL } from "../types";
+
+type Dimensions = { w: number; h: number };
+
+const getScaleToFill = (contentSize: Dimensions, containerSize: Dimensions) => {
+ const scale = Math.max(
+ containerSize.w / contentSize.w,
+ containerSize.h / contentSize.h,
+ );
+
+ return scale;
+};
+
+const getScaleToFit = (contentSize: Dimensions, containerSize: Dimensions) => {
+ const scale = Math.min(
+ containerSize.w / contentSize.w,
+ containerSize.h / contentSize.h,
+ );
+
+ return scale;
+};
+
+const addImageBackground = (
+ context: CanvasRenderingContext2D,
+ canvasWidth: number,
+ canvasHeight: number,
+ fancyBackgroundImage: HTMLImageElement,
+) => {
+ context.save();
+ context.beginPath();
+ if (context.roundRect) {
+ context.roundRect(0, 0, canvasWidth, canvasHeight, EXPORT_BG_BORDER_RADIUS);
+ } else {
+ roundRect(
+ context,
+ 0,
+ 0,
+ canvasWidth,
+ canvasHeight,
+ EXPORT_BG_BORDER_RADIUS,
+ );
+ }
+ const scale = getScaleToFill(
+ { w: fancyBackgroundImage.width, h: fancyBackgroundImage.height },
+ { w: canvasWidth, h: canvasHeight },
+ );
+ const x = (canvasWidth - fancyBackgroundImage.width * scale) / 2;
+ const y = (canvasHeight - fancyBackgroundImage.height * scale) / 2;
+ context.clip();
+ context.drawImage(
+ fancyBackgroundImage,
+ x,
+ y,
+ fancyBackgroundImage.width * scale,
+ fancyBackgroundImage.height * scale,
+ );
+ context.closePath();
+ context.restore();
+};
+
+const addContentBackground = (
+ context: CanvasRenderingContext2D,
+ canvasWidth: number,
+ canvasHeight: number,
+ contentBackgroundColor: string,
+) => {
+ const shadows = [
+ {
+ offsetX: 0,
+ offsetY: 0.7698959708213806,
+ blur: 1.4945039749145508,
+ alpha: 0.02,
+ },
+ {
+ offsetX: 0,
+ offsetY: 1.1299999952316284,
+ blur: 4.1321120262146,
+ alpha: 0.04,
+ },
+ {
+ offsetX: 0,
+ offsetY: 4.130000114440918,
+ blur: 9.94853401184082,
+ 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;
+ context.shadowOffsetX = shadow.offsetX;
+ context.shadowOffsetY = shadow.offsetY;
+
+ if (context.roundRect) {
+ context.roundRect(
+ EXPORT_BG_PADDING,
+ EXPORT_BG_PADDING,
+ canvasWidth - EXPORT_BG_PADDING * 2,
+ canvasHeight - EXPORT_BG_PADDING * 2,
+ EXPORT_BG_BORDER_RADIUS,
+ );
+ } else {
+ roundRect(
+ context,
+ EXPORT_BG_PADDING,
+ EXPORT_BG_PADDING,
+ canvasWidth - EXPORT_BG_PADDING * 2,
+ canvasHeight - EXPORT_BG_PADDING * 2,
+ EXPORT_BG_BORDER_RADIUS,
+ );
+ }
+
+ if (index === shadows.length - 1) {
+ context.fillStyle = contentBackgroundColor;
+ context.fill();
+ }
+ context.closePath();
+ context.restore();
+ });
+};
+
+export const applyFancyBackground = async (
+ canvas: HTMLCanvasElement,
+ fancyBackgroundImageUrl: DataURL,
+ backgroundColor: string,
+) => {
+ const context = canvas.getContext("2d")!;
+
+ const fancyBackgroundImage = await loadHTMLImageElement(
+ fancyBackgroundImageUrl,
+ );
+
+ addImageBackground(
+ context,
+ canvas.width,
+ canvas.height,
+ fancyBackgroundImage,
+ );
+
+ addContentBackground(context, canvas.width, canvas.height, backgroundColor);
+};
diff --git a/src/types.ts b/src/types.ts
index 7446d20ed..30f1eae49 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -238,7 +238,7 @@ export type AppState = {
pendingImageElementId: ExcalidrawImageElement["id"] | null;
showHyperlinkPopup: false | "info" | "editor";
selectedLinearElement: LinearElementEditor | null;
- exportBackgroundImage: string | null;
+ fancyBackgroundImageUrl: DataURL | null;
};
export type UIAppState = Omit<