Ver Fonte

feat: improve library preview image generation on publish (#4321)

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
David Luzar há 3 anos atrás
pai
commit
b53d1f6f3e

+ 6 - 5
src/components/App.tsx

@@ -4001,10 +4001,9 @@ class App extends React.Component<AppProps, AppState> {
     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<AppProps, AppState> {
     // 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);
 

+ 83 - 57
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);

+ 18 - 4
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<File> => {
   // 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,
+    },
   );
 };
 

+ 8 - 1
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<Blob>;
+      _create_blob(
+        this: { pica: Pica },
+        env: {
+          out_canvas: HTMLCanvasElement;
+          out_blob: Blob;
+        },
+      ): Promise<any>;
     }
 
     interface ImageBlobReduceStatic {

+ 7 - 0
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.

+ 2 - 1
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**
 

+ 30 - 4
src/packages/utils.ts

@@ -13,17 +13,19 @@ type ExportOpts = {
   elements: readonly NonDeleted<ExcalidrawElement>[];
   appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
   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,
+      };
     },
   );
 };

+ 4 - 1
src/utils.ts

@@ -151,7 +151,10 @@ export const debounce = <T extends any[]>(
 };
 
 // https://github.com/lodash/lodash/blob/es/chunk.js
-export const chunk = <T extends any>(array: T[], size: number): T[][] => {
+export const chunk = <T extends any>(
+  array: readonly T[],
+  size: number,
+): T[][] => {
   if (!array.length || size < 1) {
     return [];
   }