diff --git a/src/components/ImageExportDialog.tsx b/src/components/ImageExportDialog.tsx index c109162e4..2fcdd3c4a 100644 --- a/src/components/ImageExportDialog.tsx +++ b/src/components/ImageExportDialog.tsx @@ -39,7 +39,7 @@ import { useAppProps } from "./App"; import { FilledButton } from "./FilledButton"; import Select, { convertToSelectItems } from "./Select"; import { getCommonBounds } from "../element"; -import { defaultExportScale, distance } from "../utils"; +import { convertToExportPadding, defaultExportScale, distance } from "../utils"; import { getFancyBackgroundPadding } from "../scene/fancyBackground"; const supportsContextFilters = @@ -137,12 +137,17 @@ const ImageExportModal = ({ const maxWidth = previewNode.offsetWidth; const maxHeight = previewNode.offsetHeight; + const padding = getFancyBackgroundPadding( + convertToExportPadding(DEFAULT_EXPORT_PADDING), + true, + ); + const scale = Math.floor( (getScaleToFit( { - width: distance(minX, maxX) + getFancyBackgroundPadding() * 2, - height: distance(minY, maxY) + getFancyBackgroundPadding() * 2, + width: distance(minX, maxX) + padding[1] + padding[3], + height: distance(minY, maxY) + padding[0] + padding[2], }, { width: maxWidth, height: maxHeight }, ) + diff --git a/src/constants.ts b/src/constants.ts index c7430b676..412c1f6f7 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -234,6 +234,7 @@ export const MAX_DECIMALS_FOR_SVG_EXPORT = 2; export const EXPORT_SCALES = [1, 2, 3]; export const DEFAULT_EXPORT_PADDING = 10; // px export const FANCY_BG_PADDING = 24; // px +export const FANCY_BG_LOGO_PADDING = 20; // px export const FANCY_BG_BORDER_RADIUS = 12; // px export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440; diff --git a/src/scene/export.ts b/src/scene/export.ts index 04591b48c..651a2a8c8 100644 --- a/src/scene/export.ts +++ b/src/scene/export.ts @@ -3,11 +3,12 @@ import { NonDeletedExcalidrawElement } from "../element/types"; import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds"; import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene"; import { + convertToExportPadding, distance, expandToAspectRatio, isOnlyExportingSingleFrame, } from "../utils"; -import { AppState, BinaryFiles, Dimensions } from "../types"; +import { AppState, BinaryFiles, Dimensions, ExportPadding } from "../types"; import { DEFAULT_EXPORT_PADDING, FANCY_BACKGROUND_IMAGES, @@ -37,10 +38,12 @@ export const exportToCanvas = async ( { exportBackground, exportPadding = DEFAULT_EXPORT_PADDING, + exportLogo = true, viewBackgroundColor, }: { exportBackground: boolean; - exportPadding?: number; + exportPadding?: number | ExportPadding; + exportLogo?: boolean; viewBackgroundColor: string; }, createCanvas: ( @@ -58,9 +61,15 @@ export const exportToCanvas = async ( appState.fancyBackgroundImageKey && appState.fancyBackgroundImageKey !== "solid" && elements.length > 0; + const padding = !exportWithFancyBackground - ? exportPadding - : getFancyBackgroundPadding(exportPadding); + ? convertToExportPadding(exportPadding) + : getFancyBackgroundPadding( + convertToExportPadding(exportPadding), + exportLogo, + ); + + console.log(padding, exportPadding); const [minX, minY, width, height] = !exportWithFancyBackground ? getCanvasSize(elements, padding) @@ -83,7 +92,7 @@ export const exportToCanvas = async ( const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements); let scrollXAdjustment = 0; - let scrollYAdjustment = 0; + const scrollYAdjustment = 0; if ( exportWithFancyBackground && @@ -102,10 +111,11 @@ export const exportToCanvas = async ( exportScale: scale, theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT, contentSize, + includeLogo: exportLogo, }); - scrollXAdjustment = (width - contentSize.width - padding * 2) / 2; - scrollYAdjustment = (height - contentSize.height - padding * 2) / 2; + scrollXAdjustment = + (width - contentSize.width - (padding[1] + padding[3])) / 2; } renderStaticScene({ @@ -121,9 +131,9 @@ export const exportToCanvas = async ( ? viewBackgroundColor : null, scrollX: - -minX + (onlyExportingSingleFrame ? 0 : padding + scrollXAdjustment), + -minX + (onlyExportingSingleFrame ? 0 : padding[3] + scrollXAdjustment), scrollY: - -minY + (onlyExportingSingleFrame ? 0 : padding + scrollYAdjustment), + -minY + (onlyExportingSingleFrame ? 0 : padding[0] + scrollYAdjustment), zoom: defaultAppState.zoom, shouldCacheIgnoreZoom: false, theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT, @@ -172,8 +182,8 @@ export const exportToSvg = async ( appState.fancyBackgroundImageKey !== "solid"; const padding = !exportWithFancyBackground - ? exportPadding - : getFancyBackgroundPadding(exportPadding) * exportScale; + ? convertToExportPadding(exportPadding) + : getFancyBackgroundPadding(convertToExportPadding(exportPadding), true); let metadata = ""; if (exportEmbedScene) { @@ -228,8 +238,10 @@ export const exportToSvg = async ( const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements); - const offsetX = -minX + (onlyExportingSingleFrame ? 0 : padding); - const offsetY = -minY + (onlyExportingSingleFrame ? 0 : padding); + const offsetX = -minX + (onlyExportingSingleFrame ? 0 : padding[3]); + const offsetY = -minY + (onlyExportingSingleFrame ? 0 : padding[0]); + + console.log(offsetX, offsetY); const exportingFrame = isExportingWholeCanvas || !onlyExportingSingleFrame @@ -293,10 +305,13 @@ export const exportToSvg = async ( exportScale, theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT, contentSize, + includeLogo: true, }); - offsetXAdjustment = (width - contentSize.width - padding * 2) / 2; - offsetYAdjustment = (height - contentSize.height - padding * 2) / 2; + offsetXAdjustment = + (width - contentSize.width - (padding[1] + padding[3])) / 2; + offsetYAdjustment = + (height - contentSize.height - (padding[0] + padding[2])) / 2; } else { const rect = svgRoot.ownerDocument!.createElementNS(SVG_NS, "rect"); rect.setAttribute("x", "0"); @@ -323,7 +338,7 @@ export const exportToSvg = async ( // calculate smallest area to fit the contents in const getCanvasSize = ( elements: readonly NonDeletedExcalidrawElement[], - exportPadding: number, + exportPadding: ExportPadding, opts?: { aspectRatio: Dimensions }, ): [number, number, number, number] => { // we should decide if we are exporting the whole canvas @@ -351,11 +366,18 @@ const getCanvasSize = ( ); } - const padding = onlyExportingSingleFrame ? 0 : exportPadding * 2; - const [minX, minY, maxX, maxY] = getCommonBounds(elements); - const width = distance(minX, maxX) + padding; - const height = distance(minY, maxY) + padding; + + let width = 0; + let height = 0; + + if (onlyExportingSingleFrame) { + width = distance(minX, maxX); + height = distance(minY, maxY); + } else { + width = distance(minX, maxX) + exportPadding[1] + exportPadding[3]; + height = distance(minY, maxY) + exportPadding[0] + exportPadding[2]; + } if (opts?.aspectRatio) { const expandedDimensions = expandToAspectRatio( @@ -374,9 +396,10 @@ export const getExportSize = ( exportPadding: number, scale: number, ): [number, number] => { - const [, , width, height] = getCanvasSize(elements, exportPadding).map( - (dimension) => Math.trunc(dimension * scale), - ); + const [, , width, height] = getCanvasSize( + elements, + convertToExportPadding(exportPadding), + ).map((dimension) => Math.trunc(dimension * scale)); return [width, height]; }; diff --git a/src/scene/fancyBackground.ts b/src/scene/fancyBackground.ts index 24de96916..c9ba0733a 100644 --- a/src/scene/fancyBackground.ts +++ b/src/scene/fancyBackground.ts @@ -1,7 +1,10 @@ import { DEFAULT_EXPORT_PADDING, + EXPORT_LOGO_URL, + EXPORT_LOGO_URL_DARK, FANCY_BACKGROUND_IMAGES, FANCY_BG_BORDER_RADIUS, + FANCY_BG_LOGO_PADDING, FANCY_BG_PADDING, IMAGE_INVERT_FILTER, SVG_NS, @@ -11,11 +14,24 @@ import { import { loadHTMLImageElement, loadSVGElement } from "../element/image"; import { getScaleToFill } from "../packages/utils"; import { roundRect } from "../renderer/roundRect"; -import { AppState, DataURL, Dimensions } from "../types"; +import { AppState, DataURL, Dimensions, ExportPadding } from "../types"; export const getFancyBackgroundPadding = ( - exportPadding = DEFAULT_EXPORT_PADDING, -) => FANCY_BG_PADDING + FANCY_BG_BORDER_RADIUS + exportPadding; + 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, @@ -65,6 +81,7 @@ const getContentBackgound = ( contentSize: Dimensions, normalizedDimensions: Dimensions, exportScale: number, + includeLogo: boolean, ): { x: number; y: number; width: number; height: number } => { const x = (normalizedDimensions.width - contentSize.width * exportScale) / 2 - @@ -80,7 +97,8 @@ const getContentBackgound = ( exportScale; const height = - (contentSize.height + + (contentSize.height - + (includeLogo ? FANCY_BG_LOGO_PADDING : 0) + (DEFAULT_EXPORT_PADDING + FANCY_BG_BORDER_RADIUS) * 2) * exportScale; @@ -94,6 +112,7 @@ const addContentBackground = ( exportScale: AppState["exportScale"], theme: AppState["theme"], contentSize: Dimensions, + includeLogo: boolean, ) => { const shadows = [ { @@ -129,6 +148,7 @@ const addContentBackground = ( contentSize, normalizedDimensions, exportScale, + includeLogo, ); if (context.roundRect) { @@ -162,6 +182,26 @@ const addContentBackground = ( }); }; +const addLogo = ( + context: CanvasRenderingContext2D, + canvasDimensions: Dimensions, + logoImage: HTMLImageElement, + exportScale: number, +) => { + context.save(); + context.beginPath(); + context.drawImage( + logoImage, + ((canvasDimensions.width - logoImage.width) / 2) * exportScale, // center horizontally + (canvasDimensions.height - logoImage.height - 12) * exportScale, // 12px from bottom + logoImage.width * exportScale, + logoImage.height * exportScale, + ); + + context.closePath(); + context.restore(); +}; + export const applyFancyBackgroundOnCanvas = async ({ canvas, fancyBackgroundImageKey, @@ -169,6 +209,7 @@ export const applyFancyBackgroundOnCanvas = async ({ exportScale, theme, contentSize, + includeLogo, }: { canvas: HTMLCanvasElement; fancyBackgroundImageKey: Exclude< @@ -179,6 +220,7 @@ export const applyFancyBackgroundOnCanvas = async ({ exportScale: AppState["exportScale"]; theme: AppState["theme"]; contentSize: Dimensions; + includeLogo: boolean; }) => { const context = canvas.getContext("2d")!; @@ -208,7 +250,15 @@ export const applyFancyBackgroundOnCanvas = async ({ exportScale, theme, contentSize, + includeLogo, ); + + if (includeLogo) { + const logoImage = await loadHTMLImageElement( + theme === THEME.DARK ? EXPORT_LOGO_URL_DARK : EXPORT_LOGO_URL, + ); + addLogo(context, canvasDimensions, logoImage, exportScale); + } }; const addImageBackgroundToSvg = async ({ @@ -241,12 +291,14 @@ const addContentBackgroundToSvg = ({ contentSize, backgroundColor, dimensions, + includeLogo, }: { svgRoot: SVGSVGElement; exportScale: number; contentSize: Dimensions; backgroundColor: string; dimensions: Dimensions; + includeLogo: boolean; }) => { // Create the shadow filter const filter = svgRoot.ownerDocument!.createElementNS(SVG_NS, "filter"); @@ -305,6 +357,7 @@ const addContentBackgroundToSvg = ({ contentSize, dimensions, exportScale, + includeLogo, ); const rect = svgRoot.ownerDocument!.createElementNS(SVG_NS, "rect"); rect.setAttribute("x", x.toString()); @@ -318,6 +371,23 @@ const addContentBackgroundToSvg = ({ svgRoot.appendChild(rect); }; +const addLogoToSvg = ( + svgRoot: SVGSVGElement, + canvasDimensions: Dimensions, + logoImage: SVGSVGElement, + exportScale: number, +) => { + const logoWidth = parseFloat(logoImage.getAttribute("width") || "0"); + const logoHeight = parseFloat(logoImage.getAttribute("height") || "0"); + + const x = (canvasDimensions.width - logoWidth) / 2; // center horizontally + const y = canvasDimensions.height - logoHeight - 12; // 12px from bottom + + logoImage.setAttribute("x", `${x}`); + logoImage.setAttribute("y", `${y * exportScale}`); + svgRoot.appendChild(logoImage); +}; + export const applyFancyBackgroundOnSvg = async ({ svgRoot, fancyBackgroundImageKey, @@ -326,6 +396,7 @@ export const applyFancyBackgroundOnSvg = async ({ exportScale, theme, contentSize, + includeLogo, }: { svgRoot: SVGSVGElement; fancyBackgroundImageKey: Exclude< @@ -337,6 +408,7 @@ export const applyFancyBackgroundOnSvg = async ({ exportScale: AppState["exportScale"]; theme: AppState["theme"]; contentSize: Dimensions; + includeLogo: boolean; }) => { // Image background const fancyBackgroundImageUrl = @@ -355,5 +427,13 @@ export const applyFancyBackgroundOnSvg = async ({ contentSize, backgroundColor, dimensions, + includeLogo, }); + + if (includeLogo) { + const logoImage = await loadSVGElement( + theme === THEME.DARK ? EXPORT_LOGO_URL_DARK : EXPORT_LOGO_URL, + ); + addLogoToSvg(svgRoot, dimensions, logoImage, exportScale); + } }; diff --git a/src/types.ts b/src/types.ts index d297eb03d..4d5ea9e43 100644 --- a/src/types.ts +++ b/src/types.ts @@ -659,3 +659,4 @@ export type FrameNameBoundsCache = { }; export type Dimensions = { width: number; height: number }; +export type ExportPadding = [number, number, number, number]; diff --git a/src/utils.ts b/src/utils.ts index 908e696f9..dd15fe018 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -16,7 +16,14 @@ import { FontString, NonDeletedExcalidrawElement, } from "./element/types"; -import { AppState, DataURL, Dimensions, LastActiveTool, Zoom } from "./types"; +import { + AppState, + DataURL, + Dimensions, + ExportPadding, + LastActiveTool, + Zoom, +} from "./types"; import { unstable_batchedUpdates } from "react-dom"; import { SHAPES } from "./shapes"; import { isEraserActive, isHandToolActive } from "./appState"; @@ -1052,3 +1059,25 @@ export const expandToAspectRatio = ( height: newHeight, }; }; + +const isExportPadding = (value: any): value is ExportPadding => { + return ( + Array.isArray(value) && + value.length === 4 && + value.every((item) => typeof item === "number") + ); +}; + +export const convertToExportPadding = ( + padding: number | ExportPadding, +): ExportPadding => { + if (typeof padding === "number") { + return [padding, padding, padding, padding]; + } + + if (isExportPadding(padding)) { + return padding; + } + + throw new Error("Invalid padding value"); +};