diff --git a/src/components/App.tsx b/src/components/App.tsx index 90b966015..ced146d0b 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -4001,10 +4001,9 @@ class App extends React.Component { const existingFileData = this.files[fileId]; if (!existingFileData?.dataURL) { try { - imageFile = await resizeImageFile( - imageFile, - DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT, - ); + imageFile = await resizeImageFile(imageFile, { + maxWidthOrHeight: DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT, + }); } catch (error: any) { console.error("error trying to resing image file on insertion", error); } @@ -4113,7 +4112,9 @@ class App extends React.Component { // https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Basic_User_Interface/Using_URL_values_for_the_cursor_property const cursorImageSizePx = 96; - const imagePreview = await resizeImageFile(imageFile, cursorImageSizePx); + const imagePreview = await resizeImageFile(imageFile, { + maxWidthOrHeight: cursorImageSizePx, + }); let previewDataURL = await getDataURL(imagePreview); diff --git a/src/components/PublishLibrary.tsx b/src/components/PublishLibrary.tsx index 23a9231f7..adcc90a4a 100644 --- a/src/components/PublishLibrary.tsx +++ b/src/components/PublishLibrary.tsx @@ -1,5 +1,5 @@ import { ReactNode, useCallback, useEffect, useState } from "react"; -import oc from "open-color"; +import OpenColor from "open-color"; import { Dialog } from "./Dialog"; import { t } from "../i18n"; @@ -7,16 +7,19 @@ import { t } from "../i18n"; import { ToolButton } from "./ToolButton"; import { AppState, LibraryItems, LibraryItem } from "../types"; -import { exportToBlob } from "../packages/utils"; -import { EXPORT_DATA_TYPES, EXPORT_SOURCE, VERSIONS } from "../constants"; +import { exportToCanvas } from "../packages/utils"; +import { + EXPORT_DATA_TYPES, + EXPORT_SOURCE, + MIME_TYPES, + VERSIONS, +} from "../constants"; import { ExportedLibraryData } from "../data/types"; import "./PublishLibrary.scss"; -import { ExcalidrawElement } from "../element/types"; -import { newElement } from "../element"; -import { mutateElement } from "../element/mutateElement"; -import { getCommonBoundingBox } from "../element/bounds"; import SingleLibraryItem from "./SingleLibraryItem"; +import { canvasToBlob, resizeImageFile } from "../data/blob"; +import { chunk } from "../utils"; interface PublishLibraryDataParams { authorName: string; @@ -55,6 +58,75 @@ const importPublishLibDataFromStorage = () => { return null; }; +const generatePreviewImage = async (libraryItems: LibraryItems) => { + const MAX_ITEMS_PER_ROW = 6; + const BOX_SIZE = 128; + const BOX_PADDING = Math.round(BOX_SIZE / 16); + const BORDER_WIDTH = Math.max(Math.round(BOX_SIZE / 64), 2); + + const rows = chunk(libraryItems, MAX_ITEMS_PER_ROW); + + const canvas = document.createElement("canvas"); + + canvas.width = + rows[0].length * BOX_SIZE + + (rows[0].length + 1) * (BOX_PADDING * 2) - + BOX_PADDING * 2; + canvas.height = + rows.length * BOX_SIZE + + (rows.length + 1) * (BOX_PADDING * 2) - + BOX_PADDING * 2; + + const ctx = canvas.getContext("2d")!; + + ctx.fillStyle = OpenColor.white; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // draw items + // --------------------------------------------------------------------------- + for (const [index, item] of libraryItems.entries()) { + const itemCanvas = await exportToCanvas({ + elements: item.elements, + files: null, + maxWidthOrHeight: BOX_SIZE, + }); + + const { width, height } = itemCanvas; + + // draw item + // ------------------------------------------------------------------------- + const rowOffset = + Math.floor(index / MAX_ITEMS_PER_ROW) * (BOX_SIZE + BOX_PADDING * 2); + const colOffset = + (index % MAX_ITEMS_PER_ROW) * (BOX_SIZE + BOX_PADDING * 2); + + ctx.drawImage( + itemCanvas, + colOffset + (BOX_SIZE - width) / 2 + BOX_PADDING, + rowOffset + (BOX_SIZE - height) / 2 + BOX_PADDING, + ); + + // draw item border + // ------------------------------------------------------------------------- + ctx.lineWidth = BORDER_WIDTH; + ctx.strokeStyle = OpenColor.gray[4]; + ctx.strokeRect( + colOffset + BOX_PADDING / 2, + rowOffset + BOX_PADDING / 2, + BOX_SIZE + BOX_PADDING, + BOX_SIZE + BOX_PADDING, + ); + } + + return await resizeImageFile( + new File([await canvasToBlob(canvas)], "preview", { type: MIME_TYPES.png }), + { + outputType: MIME_TYPES.jpg, + maxWidthOrHeight: 5000, + }, + ); +}; + const PublishLibrary = ({ onClose, libraryItems, @@ -129,55 +201,8 @@ const PublishLibrary = ({ setIsSubmitting(false); return; } - const elements: ExcalidrawElement[] = []; - const prevBoundingBox = { minX: 0, minY: 0, maxX: 0, maxY: 0 }; - clonedLibItems.forEach((libItem) => { - const boundingBox = getCommonBoundingBox(libItem.elements); - const width = boundingBox.maxX - boundingBox.minX + 30; - const height = boundingBox.maxY - boundingBox.minY + 30; - const offset = { - x: prevBoundingBox.maxX - boundingBox.minX, - y: prevBoundingBox.maxY - boundingBox.minY, - }; - - const itemsWithUpdatedCoords = libItem.elements.map((element) => { - element = mutateElement(element, { - x: element.x + offset.x + 15, - y: element.y + offset.y + 15, - }); - return element; - }); - const items = [ - ...itemsWithUpdatedCoords, - newElement({ - type: "rectangle", - width, - height, - x: prevBoundingBox.maxX, - y: prevBoundingBox.maxY, - strokeColor: "#ced4da", - backgroundColor: "transparent", - strokeStyle: "solid", - opacity: 100, - roughness: 0, - strokeSharpness: "sharp", - fillStyle: "solid", - strokeWidth: 1, - }), - ]; - elements.push(...items); - prevBoundingBox.maxX = prevBoundingBox.maxX + width + 30; - }); - const png = await exportToBlob({ - elements, - mimeType: "image/png", - appState: { - ...appState, - viewBackgroundColor: oc.white, - exportBackground: true, - }, - files: null, - }); + + const previewImage = await generatePreviewImage(clonedLibItems); const libContent: ExportedLibraryData = { type: EXPORT_DATA_TYPES.excalidrawLibrary, @@ -190,7 +215,8 @@ const PublishLibrary = ({ const formData = new FormData(); formData.append("excalidrawLib", lib); - formData.append("excalidrawPng", png!); + formData.append("previewImage", previewImage); + formData.append("previewImageType", previewImage.type); formData.append("title", libraryData.name); formData.append("authorName", libraryData.authorName); formData.append("githubHandle", libraryData.githubHandle); diff --git a/src/data/blob.ts b/src/data/blob.ts index 1fc7e9f46..2418f5ace 100644 --- a/src/data/blob.ts +++ b/src/data/blob.ts @@ -237,7 +237,11 @@ export const dataURLToFile = (dataURL: DataURL, filename = "") => { export const resizeImageFile = async ( file: File, - maxWidthOrHeight: number, + opts: { + /** undefined indicates auto */ + outputType?: typeof MIME_TYPES["jpg"]; + maxWidthOrHeight: number; + }, ): Promise => { // SVG files shouldn't a can't be resized if (file.type === MIME_TYPES.svg) { @@ -257,16 +261,26 @@ export const resizeImageFile = async ( pica: pica({ features: ["js", "wasm"] }), }); - const fileType = file.type; + if (opts.outputType) { + const { outputType } = opts; + reduce._create_blob = function (env) { + return this.pica.toBlob(env.out_canvas, outputType, 0.8).then((blob) => { + env.out_blob = blob; + return env; + }); + }; + } if (!isSupportedImageFile(file)) { throw new Error(t("errors.unsupportedFileType")); } return new File( - [await reduce.toBlob(file, { max: maxWidthOrHeight })], + [await reduce.toBlob(file, { max: opts.maxWidthOrHeight })], file.name, - { type: fileType }, + { + type: opts.outputType || file.type, + }, ); }; diff --git a/src/global.d.ts b/src/global.d.ts index 639a73f39..85e8d4cef 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -111,10 +111,17 @@ interface Uint8Array { // https://github.com/nodeca/image-blob-reduce/issues/23#issuecomment-783271848 declare module "image-blob-reduce" { - import { PicaResizeOptions } from "pica"; + import { PicaResizeOptions, Pica } from "pica"; namespace ImageBlobReduce { interface ImageBlobReduce { toBlob(file: File, options: ImageBlobReduceOptions): Promise; + _create_blob( + this: { pica: Pica }, + env: { + out_canvas: HTMLCanvasElement; + out_blob: Blob; + }, + ): Promise; } interface ImageBlobReduceStatic { diff --git a/src/packages/excalidraw/CHANGELOG.md b/src/packages/excalidraw/CHANGELOG.md index 7c475086c..8db3d1504 100644 --- a/src/packages/excalidraw/CHANGELOG.md +++ b/src/packages/excalidraw/CHANGELOG.md @@ -13,6 +13,13 @@ Please add the latest change on the top under the correct section. ## Unreleased +### Features + +- Changes to [`exportToCanvas`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#exportToCanvas) util function: + + - Add `maxWidthOrHeight?: number` attribute. + - `scale` returned from `getDimensions()` is now optional (default to `1`). + - Image support. NOTE: the unreleased API is highly unstable and may change significantly before the next stable release. As such it's largely undocumented at this point. You are encouraged to read through the [PR](https://github.com/excalidraw/excalidraw/pull/4011) description if you want to know more about the internals. diff --git a/src/packages/excalidraw/README_NEXT.md b/src/packages/excalidraw/README_NEXT.md index 920f30ba5..e4c1a561a 100644 --- a/src/packages/excalidraw/README_NEXT.md +++ b/src/packages/excalidraw/README_NEXT.md @@ -756,7 +756,8 @@ This function makes sure elements and state is set to appropriate values and set | --- | --- | --- | --- | | elements | [Excalidraw Element []](https://github.com/excalidraw/excalidraw/blob/master/src/element/types) | | The elements to be exported to canvas | | appState | [AppState](https://github.com/excalidraw/excalidraw/blob/master/src/packages/utils.ts#L12) | [defaultAppState](https://github.com/excalidraw/excalidraw/blob/master/src/appState.ts#L11) | The app state of the scene | -| getDimensions | `(width: number, height: number) => {width: number, height: number, scale: number)` | `(width, height) => ({ width, height, scale: 1 })` | A function which returns the width, height and scale with which canvas is to be exported. | +| getDimensions | `(width: number, height: number) => { width: number, height: number, scale?: number }` | undefined | A function which returns the `width`, `height`, and optionally `scale` (defaults `1`), with which canvas is to be exported. | +| maxWidthOrHeight | `number` | undefined | The maximum width or height of the exported image. If provided, `getDimensions` is ignored. | **How to use** diff --git a/src/packages/utils.ts b/src/packages/utils.ts index 74c91ef11..f1f8fcb5f 100644 --- a/src/packages/utils.ts +++ b/src/packages/utils.ts @@ -13,17 +13,19 @@ type ExportOpts = { elements: readonly NonDeleted[]; appState?: Partial>; files: BinaryFiles | null; + maxWidthOrHeight?: number; getDimensions?: ( width: number, height: number, - ) => { width: number; height: number; scale: number }; + ) => { width: number; height: number; scale?: number }; }; export const exportToCanvas = ({ elements, appState, files, - getDimensions = (width, height) => ({ width, height, scale: 1 }), + maxWidthOrHeight, + getDimensions, }: ExportOpts) => { const { elements: restoredElements, appState: restoredAppState } = restore( { elements, appState }, @@ -38,12 +40,36 @@ export const exportToCanvas = ({ { exportBackground, viewBackgroundColor }, (width: number, height: number) => { const canvas = document.createElement("canvas"); - const ret = getDimensions(width, height); + + if (maxWidthOrHeight) { + if (typeof getDimensions === "function") { + console.warn( + "`getDimensions()` is ignored when `maxWidthOrHeight` is supplied.", + ); + } + + const max = Math.max(width, height); + + const scale = maxWidthOrHeight / max; + + canvas.width = width * scale; + canvas.height = height * scale; + + return { + canvas, + scale, + }; + } + + const ret = getDimensions?.(width, height) || { width, height }; canvas.width = ret.width; canvas.height = ret.height; - return { canvas, scale: ret.scale }; + return { + canvas, + scale: ret.scale ?? 1, + }; }, ); }; diff --git a/src/utils.ts b/src/utils.ts index 351832588..a3b9fe0fb 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -151,7 +151,10 @@ export const debounce = ( }; // https://github.com/lodash/lodash/blob/es/chunk.js -export const chunk = (array: T[], size: number): T[][] => { +export const chunk = ( + array: readonly T[], + size: number, +): T[][] => { if (!array.length || size < 1) { return []; }