Sfoglia il codice sorgente

feat: support adding multiple library items on canvas (#5116)

David Luzar 3 anni fa
parent
commit
d2cc76e52e

+ 1 - 1
src/actions/actionDistribute.tsx

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

+ 14 - 8
src/components/App.tsx

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

+ 4 - 2
src/components/LayerUI.tsx

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

+ 3 - 3
src/components/LibraryMenu.tsx

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

+ 23 - 5
src/components/LibraryMenuItems.tsx

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

+ 7 - 5
src/components/LibraryUnit.tsx

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

+ 10 - 4
src/data/blob.ts

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

+ 94 - 0
src/data/library.ts

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

+ 0 - 0
src/disitrubte.ts → src/distribute.ts


+ 4 - 3
src/tests/helpers/api.ts

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

+ 115 - 3
src/tests/library.test.tsx

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