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];
|
const existingFileData = this.files[fileId];
|
||||||
if (!existingFileData?.dataURL) {
|
if (!existingFileData?.dataURL) {
|
||||||
try {
|
try {
|
||||||
imageFile = await resizeImageFile(
|
imageFile = await resizeImageFile(imageFile, {
|
||||||
imageFile,
|
maxWidthOrHeight: DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT,
|
||||||
DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT,
|
});
|
||||||
);
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("error trying to resing image file on insertion", error);
|
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
|
// https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Basic_User_Interface/Using_URL_values_for_the_cursor_property
|
||||||
const cursorImageSizePx = 96;
|
const cursorImageSizePx = 96;
|
||||||
|
|
||||||
const imagePreview = await resizeImageFile(imageFile, cursorImageSizePx);
|
const imagePreview = await resizeImageFile(imageFile, {
|
||||||
|
maxWidthOrHeight: cursorImageSizePx,
|
||||||
|
});
|
||||||
|
|
||||||
let previewDataURL = await getDataURL(imagePreview);
|
let previewDataURL = await getDataURL(imagePreview);
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ReactNode, useCallback, useEffect, useState } from "react";
|
import { ReactNode, useCallback, useEffect, useState } from "react";
|
||||||
import oc from "open-color";
|
import OpenColor from "open-color";
|
||||||
|
|
||||||
import { Dialog } from "./Dialog";
|
import { Dialog } from "./Dialog";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@ -7,16 +7,19 @@ import { t } from "../i18n";
|
|||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
|
|
||||||
import { AppState, LibraryItems, LibraryItem } from "../types";
|
import { AppState, LibraryItems, LibraryItem } from "../types";
|
||||||
import { exportToBlob } from "../packages/utils";
|
import { exportToCanvas } from "../packages/utils";
|
||||||
import { EXPORT_DATA_TYPES, EXPORT_SOURCE, VERSIONS } from "../constants";
|
import {
|
||||||
|
EXPORT_DATA_TYPES,
|
||||||
|
EXPORT_SOURCE,
|
||||||
|
MIME_TYPES,
|
||||||
|
VERSIONS,
|
||||||
|
} from "../constants";
|
||||||
import { ExportedLibraryData } from "../data/types";
|
import { ExportedLibraryData } from "../data/types";
|
||||||
|
|
||||||
import "./PublishLibrary.scss";
|
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 SingleLibraryItem from "./SingleLibraryItem";
|
||||||
|
import { canvasToBlob, resizeImageFile } from "../data/blob";
|
||||||
|
import { chunk } from "../utils";
|
||||||
|
|
||||||
interface PublishLibraryDataParams {
|
interface PublishLibraryDataParams {
|
||||||
authorName: string;
|
authorName: string;
|
||||||
@ -55,6 +58,75 @@ const importPublishLibDataFromStorage = () => {
|
|||||||
return null;
|
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 = ({
|
const PublishLibrary = ({
|
||||||
onClose,
|
onClose,
|
||||||
libraryItems,
|
libraryItems,
|
||||||
@ -129,55 +201,8 @@ const PublishLibrary = ({
|
|||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
return;
|
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) => {
|
const previewImage = await generatePreviewImage(clonedLibItems);
|
||||||
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 libContent: ExportedLibraryData = {
|
const libContent: ExportedLibraryData = {
|
||||||
type: EXPORT_DATA_TYPES.excalidrawLibrary,
|
type: EXPORT_DATA_TYPES.excalidrawLibrary,
|
||||||
@ -190,7 +215,8 @@ const PublishLibrary = ({
|
|||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("excalidrawLib", lib);
|
formData.append("excalidrawLib", lib);
|
||||||
formData.append("excalidrawPng", png!);
|
formData.append("previewImage", previewImage);
|
||||||
|
formData.append("previewImageType", previewImage.type);
|
||||||
formData.append("title", libraryData.name);
|
formData.append("title", libraryData.name);
|
||||||
formData.append("authorName", libraryData.authorName);
|
formData.append("authorName", libraryData.authorName);
|
||||||
formData.append("githubHandle", libraryData.githubHandle);
|
formData.append("githubHandle", libraryData.githubHandle);
|
||||||
|
@ -237,7 +237,11 @@ export const dataURLToFile = (dataURL: DataURL, filename = "") => {
|
|||||||
|
|
||||||
export const resizeImageFile = async (
|
export const resizeImageFile = async (
|
||||||
file: File,
|
file: File,
|
||||||
maxWidthOrHeight: number,
|
opts: {
|
||||||
|
/** undefined indicates auto */
|
||||||
|
outputType?: typeof MIME_TYPES["jpg"];
|
||||||
|
maxWidthOrHeight: number;
|
||||||
|
},
|
||||||
): Promise<File> => {
|
): Promise<File> => {
|
||||||
// SVG files shouldn't a can't be resized
|
// SVG files shouldn't a can't be resized
|
||||||
if (file.type === MIME_TYPES.svg) {
|
if (file.type === MIME_TYPES.svg) {
|
||||||
@ -257,16 +261,26 @@ export const resizeImageFile = async (
|
|||||||
pica: pica({ features: ["js", "wasm"] }),
|
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)) {
|
if (!isSupportedImageFile(file)) {
|
||||||
throw new Error(t("errors.unsupportedFileType"));
|
throw new Error(t("errors.unsupportedFileType"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new File(
|
return new File(
|
||||||
[await reduce.toBlob(file, { max: maxWidthOrHeight })],
|
[await reduce.toBlob(file, { max: opts.maxWidthOrHeight })],
|
||||||
file.name,
|
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
|
// https://github.com/nodeca/image-blob-reduce/issues/23#issuecomment-783271848
|
||||||
declare module "image-blob-reduce" {
|
declare module "image-blob-reduce" {
|
||||||
import { PicaResizeOptions } from "pica";
|
import { PicaResizeOptions, Pica } from "pica";
|
||||||
namespace ImageBlobReduce {
|
namespace ImageBlobReduce {
|
||||||
interface ImageBlobReduce {
|
interface ImageBlobReduce {
|
||||||
toBlob(file: File, options: ImageBlobReduceOptions): Promise<Blob>;
|
toBlob(file: File, options: ImageBlobReduceOptions): Promise<Blob>;
|
||||||
|
_create_blob(
|
||||||
|
this: { pica: Pica },
|
||||||
|
env: {
|
||||||
|
out_canvas: HTMLCanvasElement;
|
||||||
|
out_blob: Blob;
|
||||||
|
},
|
||||||
|
): Promise<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImageBlobReduceStatic {
|
interface ImageBlobReduceStatic {
|
||||||
|
@ -13,6 +13,13 @@ Please add the latest change on the top under the correct section.
|
|||||||
|
|
||||||
## Unreleased
|
## 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.
|
- 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.
|
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 |
|
| 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 |
|
| 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**
|
**How to use**
|
||||||
|
|
||||||
|
@ -13,17 +13,19 @@ type ExportOpts = {
|
|||||||
elements: readonly NonDeleted<ExcalidrawElement>[];
|
elements: readonly NonDeleted<ExcalidrawElement>[];
|
||||||
appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
|
appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
|
||||||
files: BinaryFiles | null;
|
files: BinaryFiles | null;
|
||||||
|
maxWidthOrHeight?: number;
|
||||||
getDimensions?: (
|
getDimensions?: (
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
) => { width: number; height: number; scale: number };
|
) => { width: number; height: number; scale?: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const exportToCanvas = ({
|
export const exportToCanvas = ({
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
files,
|
files,
|
||||||
getDimensions = (width, height) => ({ width, height, scale: 1 }),
|
maxWidthOrHeight,
|
||||||
|
getDimensions,
|
||||||
}: ExportOpts) => {
|
}: ExportOpts) => {
|
||||||
const { elements: restoredElements, appState: restoredAppState } = restore(
|
const { elements: restoredElements, appState: restoredAppState } = restore(
|
||||||
{ elements, appState },
|
{ elements, appState },
|
||||||
@ -38,12 +40,36 @@ export const exportToCanvas = ({
|
|||||||
{ exportBackground, viewBackgroundColor },
|
{ exportBackground, viewBackgroundColor },
|
||||||
(width: number, height: number) => {
|
(width: number, height: number) => {
|
||||||
const canvas = document.createElement("canvas");
|
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.width = ret.width;
|
||||||
canvas.height = ret.height;
|
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
|
// 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) {
|
if (!array.length || size < 1) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user