From a1a31b43714b1b6cd8c1b087693d902fe5ae3bfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Thu, 10 Aug 2023 17:40:29 +0200 Subject: [PATCH] feat: separate fancyBackground from renderScene --- src/appState.ts | 4 +- src/components/ImageExportDialog.tsx | 12 +- src/constants.ts | 15 +- src/renderer/renderScene.ts | 291 ++++----------------------- src/scene/export.ts | 18 +- src/scene/fancyBackground.ts | 147 ++++++++++++++ src/types.ts | 2 +- 7 files changed, 216 insertions(+), 273 deletions(-) create mode 100644 src/scene/fancyBackground.ts 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<