feat: support adding multiple library items on canvas (#5116)
This commit is contained in:
parent
cad6097d60
commit
d2cc76e52e
@ -3,7 +3,7 @@ import {
|
||||
DistributeVerticallyIcon,
|
||||
} from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { distributeElements, Distribution } from "../disitrubte";
|
||||
import { distributeElements, Distribution } from "../distribute";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
|
@ -74,7 +74,7 @@ import {
|
||||
ZOOM_STEP,
|
||||
} from "../constants";
|
||||
import { loadFromBlob } from "../data";
|
||||
import Library from "../data/library";
|
||||
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
||||
import { restore, restoreElements } from "../data/restore";
|
||||
import {
|
||||
dragNewElement,
|
||||
@ -232,6 +232,7 @@ import {
|
||||
isSupportedImageFile,
|
||||
loadSceneOrLibraryFromBlob,
|
||||
normalizeFile,
|
||||
parseLibraryJSON,
|
||||
resizeImageFile,
|
||||
SVGStringToFile,
|
||||
} from "../data/blob";
|
||||
@ -5212,13 +5213,18 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
}
|
||||
|
||||
const libraryShapes = event.dataTransfer.getData(MIME_TYPES.excalidrawlib);
|
||||
if (libraryShapes !== "") {
|
||||
this.addElementsFromPasteOrLibrary({
|
||||
elements: JSON.parse(libraryShapes),
|
||||
position: event,
|
||||
files: null,
|
||||
});
|
||||
const libraryJSON = event.dataTransfer.getData(MIME_TYPES.excalidrawlib);
|
||||
if (libraryJSON && typeof libraryJSON === "string") {
|
||||
try {
|
||||
const libraryItems = parseLibraryJSON(libraryJSON);
|
||||
this.addElementsFromPasteOrLibrary({
|
||||
elements: distributeLibraryItemsOnSquareGrid(libraryItems),
|
||||
position: event,
|
||||
files: null,
|
||||
});
|
||||
} catch (error: any) {
|
||||
this.setState({ errorMessage: error.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -27,7 +27,7 @@ import { HelpDialog } from "./HelpDialog";
|
||||
import Stack from "./Stack";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { UserList } from "./UserList";
|
||||
import Library from "../data/library";
|
||||
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
||||
import { JSONExportDialog } from "./JSONExportDialog";
|
||||
import { LibraryButton } from "./LibraryButton";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
@ -277,7 +277,9 @@ const LayerUI = ({
|
||||
<LibraryMenu
|
||||
pendingElements={getSelectedElements(elements, appState, true)}
|
||||
onClose={closeLibrary}
|
||||
onInsertShape={onInsertElements}
|
||||
onInsertLibraryItems={(libraryItems) => {
|
||||
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
|
||||
}}
|
||||
onAddToLibrary={deselectItems}
|
||||
setAppState={setAppState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
|
@ -76,7 +76,7 @@ const LibraryMenuWrapper = forwardRef<
|
||||
|
||||
export const LibraryMenu = ({
|
||||
onClose,
|
||||
onInsertShape,
|
||||
onInsertLibraryItems,
|
||||
pendingElements,
|
||||
onAddToLibrary,
|
||||
theme,
|
||||
@ -90,7 +90,7 @@ export const LibraryMenu = ({
|
||||
}: {
|
||||
pendingElements: LibraryItem["elements"];
|
||||
onClose: () => void;
|
||||
onInsertShape: (elements: LibraryItem["elements"]) => void;
|
||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||
onAddToLibrary: () => void;
|
||||
theme: AppState["theme"];
|
||||
files: BinaryFiles;
|
||||
@ -270,7 +270,7 @@ export const LibraryMenu = ({
|
||||
onAddToLibrary={(elements) =>
|
||||
addToLibrary(elements, libraryItemsData.libraryItems)
|
||||
}
|
||||
onInsertShape={onInsertShape}
|
||||
onInsertLibraryItems={onInsertLibraryItems}
|
||||
pendingElements={pendingElements}
|
||||
setAppState={setAppState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { chunk } from "lodash";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { saveLibraryAsJSON } from "../data/json";
|
||||
import { saveLibraryAsJSON, serializeLibraryAsJSON } from "../data/json";
|
||||
import Library from "../data/library";
|
||||
import { ExcalidrawElement, NonDeleted } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
@ -21,7 +21,7 @@ import { ToolButton } from "./ToolButton";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
import "./LibraryMenuItems.scss";
|
||||
import { VERSIONS } from "../constants";
|
||||
import { MIME_TYPES, VERSIONS } from "../constants";
|
||||
import Spinner from "./Spinner";
|
||||
import { fileOpen } from "../data/filesystem";
|
||||
|
||||
@ -30,7 +30,7 @@ const LibraryMenuItems = ({
|
||||
libraryItems,
|
||||
onRemoveFromLibrary,
|
||||
onAddToLibrary,
|
||||
onInsertShape,
|
||||
onInsertLibraryItems,
|
||||
pendingElements,
|
||||
theme,
|
||||
setAppState,
|
||||
@ -47,7 +47,7 @@ const LibraryMenuItems = ({
|
||||
libraryItems: LibraryItems;
|
||||
pendingElements: LibraryItem["elements"];
|
||||
onRemoveFromLibrary: () => void;
|
||||
onInsertShape: (elements: LibraryItem["elements"]) => void;
|
||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||
onAddToLibrary: (elements: LibraryItem["elements"]) => void;
|
||||
theme: AppState["theme"];
|
||||
files: BinaryFiles;
|
||||
@ -252,6 +252,18 @@ const LibraryMenuItems = ({
|
||||
}
|
||||
};
|
||||
|
||||
const getInsertedElements = (id: string) => {
|
||||
let targetElements;
|
||||
if (selectedItems.includes(id)) {
|
||||
targetElements = libraryItems.filter((item) =>
|
||||
selectedItems.includes(item.id),
|
||||
);
|
||||
} else {
|
||||
targetElements = libraryItems.filter((item) => item.id === id);
|
||||
}
|
||||
return targetElements;
|
||||
};
|
||||
|
||||
const createLibraryItemCompo = (params: {
|
||||
item:
|
||||
| LibraryItem
|
||||
@ -273,6 +285,12 @@ const LibraryMenuItems = ({
|
||||
id={params.item?.id || null}
|
||||
selected={!!params.item?.id && selectedItems.includes(params.item.id)}
|
||||
onToggle={onItemSelectToggle}
|
||||
onDrag={(id, event) => {
|
||||
event.dataTransfer.setData(
|
||||
MIME_TYPES.excalidrawlib,
|
||||
serializeLibraryAsJSON(getInsertedElements(id)),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Stack.Col>
|
||||
);
|
||||
@ -291,7 +309,7 @@ const LibraryMenuItems = ({
|
||||
if (item.id) {
|
||||
return createLibraryItemCompo({
|
||||
item,
|
||||
onClick: () => onInsertShape(item.elements),
|
||||
onClick: () => onInsertLibraryItems(getInsertedElements(item.id)),
|
||||
key: item.id,
|
||||
});
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
import clsx from "clsx";
|
||||
import oc from "open-color";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { useDeviceType } from "../components/App";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import { BinaryFiles, LibraryItem } from "../types";
|
||||
@ -29,6 +28,7 @@ export const LibraryUnit = ({
|
||||
onClick,
|
||||
selected,
|
||||
onToggle,
|
||||
onDrag,
|
||||
}: {
|
||||
id: LibraryItem["id"] | /** for pending item */ null;
|
||||
elements?: LibraryItem["elements"];
|
||||
@ -37,6 +37,7 @@ export const LibraryUnit = ({
|
||||
onClick: () => void;
|
||||
selected: boolean;
|
||||
onToggle: (id: string, event: React.MouseEvent) => void;
|
||||
onDrag: (id: string, event: React.DragEvent) => void;
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
@ -99,11 +100,12 @@ export const LibraryUnit = ({
|
||||
: undefined
|
||||
}
|
||||
onDragStart={(event) => {
|
||||
if (!id) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
setIsHovered(false);
|
||||
event.dataTransfer.setData(
|
||||
MIME_TYPES.excalidrawlib,
|
||||
JSON.stringify(elements),
|
||||
);
|
||||
onDrag(id, event);
|
||||
}}
|
||||
/>
|
||||
{adder}
|
||||
|
@ -191,12 +191,11 @@ export const loadFromBlob = async (
|
||||
return ret.data;
|
||||
};
|
||||
|
||||
export const loadLibraryFromBlob = async (
|
||||
blob: Blob,
|
||||
export const parseLibraryJSON = (
|
||||
json: string,
|
||||
defaultStatus: LibraryItem["status"] = "unpublished",
|
||||
) => {
|
||||
const contents = await parseFileContents(blob);
|
||||
const data: ImportedLibraryData | undefined = JSON.parse(contents);
|
||||
const data: ImportedLibraryData | undefined = JSON.parse(json);
|
||||
if (!isValidLibrary(data)) {
|
||||
throw new Error("Invalid library");
|
||||
}
|
||||
@ -204,6 +203,13 @@ export const loadLibraryFromBlob = async (
|
||||
return restoreLibraryItems(libraryItems, defaultStatus);
|
||||
};
|
||||
|
||||
export const loadLibraryFromBlob = async (
|
||||
blob: Blob,
|
||||
defaultStatus: LibraryItem["status"] = "unpublished",
|
||||
) => {
|
||||
return parseLibraryJSON(await parseFileContents(blob), defaultStatus);
|
||||
};
|
||||
|
||||
export const canvasToBlob = async (
|
||||
canvas: HTMLCanvasElement,
|
||||
): Promise<Blob> => {
|
||||
|
@ -9,6 +9,8 @@ import { restoreLibraryItems } from "./restore";
|
||||
import type App from "../components/App";
|
||||
import { atom } from "jotai";
|
||||
import { jotaiStore } from "../jotai";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { getCommonBoundingBox } from "../element/bounds";
|
||||
import { AbortError } from "../errors";
|
||||
import { t } from "../i18n";
|
||||
import { useEffect, useRef } from "react";
|
||||
@ -242,6 +244,98 @@ class Library {
|
||||
|
||||
export default Library;
|
||||
|
||||
export const distributeLibraryItemsOnSquareGrid = (
|
||||
libraryItems: LibraryItems,
|
||||
) => {
|
||||
const PADDING = 50;
|
||||
const ITEMS_PER_ROW = Math.ceil(Math.sqrt(libraryItems.length));
|
||||
|
||||
const resElements: ExcalidrawElement[] = [];
|
||||
|
||||
const getMaxHeightPerRow = (row: number) => {
|
||||
const maxHeight = libraryItems
|
||||
.slice(row * ITEMS_PER_ROW, row * ITEMS_PER_ROW + ITEMS_PER_ROW)
|
||||
.reduce((acc, item) => {
|
||||
const { height } = getCommonBoundingBox(item.elements);
|
||||
return Math.max(acc, height);
|
||||
}, 0);
|
||||
return maxHeight;
|
||||
};
|
||||
|
||||
const getMaxWidthPerCol = (targetCol: number) => {
|
||||
let index = 0;
|
||||
let currCol = 0;
|
||||
let maxWidth = 0;
|
||||
for (const item of libraryItems) {
|
||||
if (index % ITEMS_PER_ROW === 0) {
|
||||
currCol = 0;
|
||||
}
|
||||
if (currCol === targetCol) {
|
||||
const { width } = getCommonBoundingBox(item.elements);
|
||||
maxWidth = Math.max(maxWidth, width);
|
||||
}
|
||||
index++;
|
||||
currCol++;
|
||||
}
|
||||
return maxWidth;
|
||||
};
|
||||
|
||||
let colOffsetX = 0;
|
||||
let rowOffsetY = 0;
|
||||
|
||||
let maxHeightCurrRow = 0;
|
||||
let maxWidthCurrCol = 0;
|
||||
|
||||
let index = 0;
|
||||
let col = 0;
|
||||
let row = 0;
|
||||
|
||||
for (const item of libraryItems) {
|
||||
if (index && index % ITEMS_PER_ROW === 0) {
|
||||
rowOffsetY += maxHeightCurrRow + PADDING;
|
||||
colOffsetX = 0;
|
||||
col = 0;
|
||||
row++;
|
||||
}
|
||||
|
||||
if (col === 0) {
|
||||
maxHeightCurrRow = getMaxHeightPerRow(row);
|
||||
}
|
||||
maxWidthCurrCol = getMaxWidthPerCol(col);
|
||||
|
||||
const { minX, minY, width, height } = getCommonBoundingBox(item.elements);
|
||||
const offsetCenterX = (maxWidthCurrCol - width) / 2;
|
||||
const offsetCenterY = (maxHeightCurrRow - height) / 2;
|
||||
resElements.push(
|
||||
// eslint-disable-next-line no-loop-func
|
||||
...item.elements.map((element) => ({
|
||||
...element,
|
||||
x:
|
||||
element.x +
|
||||
// offset for column
|
||||
colOffsetX +
|
||||
// offset to center in given square grid
|
||||
offsetCenterX -
|
||||
// subtract minX so that given item starts at 0 coord
|
||||
minX,
|
||||
y:
|
||||
element.y +
|
||||
// offset for row
|
||||
rowOffsetY +
|
||||
// offset to center in given square grid
|
||||
offsetCenterY -
|
||||
// subtract minY so that given item starts at 0 coord
|
||||
minY,
|
||||
})),
|
||||
);
|
||||
colOffsetX += maxWidthCurrCol + PADDING;
|
||||
index++;
|
||||
col++;
|
||||
}
|
||||
|
||||
return resElements;
|
||||
};
|
||||
|
||||
export const parseLibraryTokensFromUrl = () => {
|
||||
const libraryUrl =
|
||||
// current
|
||||
|
@ -59,9 +59,10 @@ export class API {
|
||||
};
|
||||
|
||||
static createElement = <
|
||||
T extends Exclude<ExcalidrawElement["type"], "selection">,
|
||||
T extends Exclude<ExcalidrawElement["type"], "selection"> = "rectangle",
|
||||
>({
|
||||
type,
|
||||
// @ts-ignore
|
||||
type = "rectangle",
|
||||
id,
|
||||
x = 0,
|
||||
y = x,
|
||||
@ -71,7 +72,7 @@ export class API {
|
||||
groupIds = [],
|
||||
...rest
|
||||
}: {
|
||||
type: T;
|
||||
type?: T;
|
||||
x?: number;
|
||||
y?: number;
|
||||
height?: number;
|
||||
|
@ -2,8 +2,12 @@ import { render, waitFor } from "./test-utils";
|
||||
import ExcalidrawApp from "../excalidraw-app";
|
||||
import { API } from "./helpers/api";
|
||||
import { MIME_TYPES } from "../constants";
|
||||
import { LibraryItem } from "../types";
|
||||
import { LibraryItem, LibraryItems } from "../types";
|
||||
import { UI } from "./helpers/ui";
|
||||
import { serializeLibraryAsJSON } from "../data/json";
|
||||
import { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
||||
import { ExcalidrawGenericElement } from "../element/types";
|
||||
import { getCommonBoundingBox } from "../element/bounds";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
@ -37,7 +41,7 @@ describe("library", () => {
|
||||
await API.readFile("./fixtures/fixture_library.excalidrawlib", "utf8"),
|
||||
).library[0];
|
||||
await API.drop(
|
||||
new Blob([JSON.stringify(libraryItems)], {
|
||||
new Blob([serializeLibraryAsJSON([libraryItems])], {
|
||||
type: MIME_TYPES.excalidrawlib,
|
||||
}),
|
||||
);
|
||||
@ -53,7 +57,7 @@ describe("library", () => {
|
||||
await API.readFile("./fixtures/fixture_library.excalidrawlib", "utf8"),
|
||||
).library[0];
|
||||
await API.drop(
|
||||
new Blob([JSON.stringify(libraryItems)], {
|
||||
new Blob([serializeLibraryAsJSON([libraryItems])], {
|
||||
type: MIME_TYPES.excalidrawlib,
|
||||
}),
|
||||
);
|
||||
@ -63,3 +67,111 @@ describe("library", () => {
|
||||
expect(h.state.activeTool.type).toBe("selection");
|
||||
});
|
||||
});
|
||||
|
||||
describe("distributeLibraryItemsOnSquareGrid()", () => {
|
||||
it("should distribute items on a grid", async () => {
|
||||
const createLibraryItem = (
|
||||
elements: ExcalidrawGenericElement[],
|
||||
): LibraryItem => {
|
||||
return {
|
||||
id: `id-${Date.now()}`,
|
||||
elements,
|
||||
status: "unpublished",
|
||||
created: Date.now(),
|
||||
};
|
||||
};
|
||||
|
||||
const PADDING = 50;
|
||||
|
||||
const el1 = API.createElement({
|
||||
id: "id1",
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const el2 = API.createElement({
|
||||
id: "id2",
|
||||
width: 100,
|
||||
height: 80,
|
||||
x: -100,
|
||||
y: -50,
|
||||
});
|
||||
|
||||
const el3 = API.createElement({
|
||||
id: "id3",
|
||||
width: 40,
|
||||
height: 50,
|
||||
x: -100,
|
||||
y: -50,
|
||||
});
|
||||
|
||||
const el4 = API.createElement({
|
||||
id: "id4",
|
||||
width: 50,
|
||||
height: 50,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const el5 = API.createElement({
|
||||
id: "id5",
|
||||
width: 70,
|
||||
height: 100,
|
||||
x: 40,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const libraryItems: LibraryItems = [
|
||||
createLibraryItem([el1]),
|
||||
createLibraryItem([el2]),
|
||||
createLibraryItem([el3]),
|
||||
createLibraryItem([el4, el5]),
|
||||
];
|
||||
|
||||
const distributed = distributeLibraryItemsOnSquareGrid(libraryItems);
|
||||
// assert the returned library items are flattened to elements
|
||||
expect(distributed.length).toEqual(
|
||||
libraryItems.map((x) => x.elements).flat().length,
|
||||
);
|
||||
expect(distributed).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: el1.id,
|
||||
x: 0,
|
||||
y: 0,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: el2.id,
|
||||
x:
|
||||
el1.width +
|
||||
PADDING +
|
||||
(getCommonBoundingBox([el4, el5]).width - el2.width) / 2,
|
||||
y: Math.abs(el1.height - el2.height) / 2,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: el3.id,
|
||||
x: Math.abs(el1.width - el3.width) / 2,
|
||||
y:
|
||||
Math.max(el1.height, el2.height) +
|
||||
PADDING +
|
||||
Math.abs(el3.height - Math.max(el4.height, el5.height)) / 2,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: el4.id,
|
||||
x: Math.max(el1.width, el2.width) + PADDING,
|
||||
y: Math.max(el1.height, el2.height) + PADDING,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: el5.id,
|
||||
x: Math.max(el1.width, el2.width) + PADDING + Math.abs(el5.x - el4.x),
|
||||
y:
|
||||
Math.max(el1.height, el2.height) +
|
||||
PADDING +
|
||||
Math.abs(el5.y - el4.y),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user