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

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
pull/4306/head
David Luzar 3 years ago committed by GitHub
parent ca1f3aa094
commit b53d1f6f3e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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);

@ -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);

@ -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,
},
);
};

9
src/global.d.ts vendored

@ -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 {

@ -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.

@ -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**

@ -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,
};
},
);
};

@ -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 [];
}

Loading…
Cancel
Save