feat: improve library preview image generation on publish (#4321)
Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
This commit is contained in:
parent
ca1f3aa094
commit
b53d1f6f3e
@ -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
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…
x
Reference in New Issue
Block a user