diff --git a/src/actions/actionDistribute.tsx b/src/actions/actionDistribute.tsx index 8bfeb90c..da5095a5 100644 --- a/src/actions/actionDistribute.tsx +++ b/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"; diff --git a/src/components/App.tsx b/src/components/App.tsx index b1134d16..b95d0c8f 100644 --- a/src/components/App.tsx +++ b/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 { }); } - 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; } diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 7e0dabbc..6e2ea29d 100644 --- a/src/components/LayerUI.tsx +++ b/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 = ({ { + onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems)); + }} onAddToLibrary={deselectItems} setAppState={setAppState} libraryReturnUrl={libraryReturnUrl} diff --git a/src/components/LibraryMenu.tsx b/src/components/LibraryMenu.tsx index 025b893e..f95451e2 100644 --- a/src/components/LibraryMenu.tsx +++ b/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} diff --git a/src/components/LibraryMenuItems.tsx b/src/components/LibraryMenuItems.tsx index 5cddf556..a1c18355 100644 --- a/src/components/LibraryMenuItems.tsx +++ b/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)), + ); + }} /> ); @@ -291,7 +309,7 @@ const LibraryMenuItems = ({ if (item.id) { return createLibraryItemCompo({ item, - onClick: () => onInsertShape(item.elements), + onClick: () => onInsertLibraryItems(getInsertedElements(item.id)), key: item.id, }); } diff --git a/src/components/LibraryUnit.tsx b/src/components/LibraryUnit.tsx index a24a4c5c..46f8d11b 100644 --- a/src/components/LibraryUnit.tsx +++ b/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(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} diff --git a/src/data/blob.ts b/src/data/blob.ts index 7e1e6cf9..ceecd934 100644 --- a/src/data/blob.ts +++ b/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 => { diff --git a/src/data/library.ts b/src/data/library.ts index b4bb1352..c19f54a6 100644 --- a/src/data/library.ts +++ b/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 diff --git a/src/disitrubte.ts b/src/distribute.ts similarity index 100% rename from src/disitrubte.ts rename to src/distribute.ts diff --git a/src/tests/helpers/api.ts b/src/tests/helpers/api.ts index 8944bfdb..f0afdd2c 100644 --- a/src/tests/helpers/api.ts +++ b/src/tests/helpers/api.ts @@ -59,9 +59,10 @@ export class API { }; static createElement = < - T extends Exclude, + T extends Exclude = "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; diff --git a/src/tests/library.test.tsx b/src/tests/library.test.tsx index 3d433940..a9e04741 100644 --- a/src/tests/library.test.tsx +++ b/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), + }), + ]), + ); + }); +});