Browse Source

feat: Allow publishing libraries from UI (#4115)

* feat: Allow publishing libraries from UI

* Add status for each library item and show publish only for unpublished libs

* Add publish library dialog

* Pass the data to publish the library

* pass lib blob

* Handle old and new libraries when importing

* Better error handling

* Show publish success when library submitted for review

* don't close library when publish success dialog open

* Support multiple libs deletion and publish

* Set status to published once library submitted for review

* Save  to LS after library published

* unique key for publish and delete

* fix layout shift when hover and also highlight selected library items

* design improvements

* migrate old library to the new one

* fix

* fix tests

* use i18n

* Support submit type in toolbutton

* Use html5 form validation, add asteriks for required fields, add twitter handle, mark github handle optional

* Add twitter handle in form state

* revert html5 validation as fetch is giving some issues :/

* clarify types around LibraryItems

* Add website optional field

* event.preventDefault to make htm5 form validationw work

* improve png generation by drawing a bounding box rect and aligining pngs to support multiple libs png

* remove ts-ignore

* add placeholders for fields

* decrease clickable area for checkbox by 0.5em

* add checkbox background color

* rename `items` to `elements`

* improve checkbox hit area

* show selected library items in publish dialog

* decrease dimensions by 3px to improve jerky experience when opening/closing library menu

* Don't close publish dialog when clicked outside

* Show selected library actions only when any library item selected and use icons instead of button

* rename library to libraryItems in excalidrawLib and added migration

* change icon and swap bg/color

* use blue brand color for hover/selected states

* prompt for confirmation when deleting library items

* separate unpublished items from published

* factor `LibraryMenu` into own file

* i18n and minor fixes for unpublished items

* fix not rendering empty cells when library empty

* don't render published section if empty and unpublished is not

* Add edit name functionality for library items

* fix

* edit lib name with onchange/blur

* bump library version

* prefer response error message

* add library urls to ENV vars

* mark lib item name as required

* Use input only for lib item name

* better error validation for lib items

* fix label styling for lib items

* design and i18n fixes

* Save publish dialog data to local storage and clear once published

* Add a note about MIT License

* Add note for guidelines

* Add tooltip for publish button

* Show spinner in submit button when submission is in progress

* assign id for older lib items when installed and set status as published for all lib when installed

* update export icon and support export library for selected items

* move LibraryMenuItems into its own component as its best to keep one comp per file

* fix spec

* Refactoring the library actions for reusablility

* show only load when items not present

* close on click outside in publish dialog

* ad dialog description and tweak copy

* vertically center input labels

* align input styles

* move author name input to other usernames

* rename param

* inline to simplify

* fix to not inline `undefined` class names

* fix version & include only latest lib schema in library export type

* await response callback

* refactor types

* refactor

* i18n

* align casing & tweaks

* move ls logic to publishLibrary

* support removal of item inside publish dialog

* fix labels for trash icon when items selected

* replace window.confirm for removal libs with confirm dialog

* fix input/textarea styling

* move library item menu scss to its own file

* use blue for load and cyan for publish

* reduce margin for submit and make submit => Submit

* Make library items header sticky

* move publish icon to left so there is no jerkiness when unpublish items selected

* update url

* fix grid gap between lib items

* Mark older items imported from initial data as unpublished

* add text to publish button on non-mobile

* add items counter

* fix test

* show personal and excal libs sections and personal goes first

* show toast on adding to library via contextMenu

* Animate plus icon and not the pending item

* fix snap

* use i18n when no item in publish dialog

* tweak style of new lib item

* show empty cells for both sections and set status as published for installed libs

* fix

* push selected item first in unpublished section

* set status as published for imported from webiste but unpublished for json

* Add items to the begining of library

* add `created` library item attr

* fix test

* use `defaultValue` instead of `value`

* fix dark theme styles

* fix toggle button not closing library

* close library menu on Escape

* tweak publish dialog item remove style

* fix remove icon in publish dialog

Co-authored-by: dwelle <luzar.david@gmail.com>
vanilla_orig
Aakansha Doshi 3 years ago
parent
commit
84d1d9993c
No account linked to committer's email address
44 changed files with 1864 additions and 499 deletions
  1. 3
    1
      .env
  2. 6
    1
      .env.production
  3. 36
    12
      src/actions/actionAddToLibrary.ts
  4. 1
    13
      src/align.ts
  5. 6
    3
      src/components/App.tsx
  6. 3
    2
      src/components/CheckboxItem.tsx
  7. 3
    0
      src/components/Dialog.tsx
  8. 0
    36
      src/components/LayerUI.scss
  9. 14
    322
      src/components/LayerUI.tsx
  10. 55
    0
      src/components/LibraryMenu.scss
  11. 287
    0
      src/components/LibraryMenu.tsx
  12. 102
    0
      src/components/LibraryMenuItems.scss
  13. 322
    0
      src/components/LibraryMenuItems.tsx
  14. 59
    15
      src/components/LibraryUnit.scss
  15. 25
    24
      src/components/LibraryUnit.tsx
  16. 6
    2
      src/components/Modal.tsx
  17. 1
    1
      src/components/PasteChartDialog.tsx
  18. 1
    0
      src/components/ProjectName.tsx
  19. 92
    0
      src/components/PublishLibrary.scss
  20. 430
    0
      src/components/PublishLibrary.tsx
  21. 66
    0
      src/components/SingleLibraryItem.scss
  22. 99
    0
      src/components/SingleLibraryItem.tsx
  23. 0
    18
      src/components/TextInput.scss
  24. 17
    3
      src/components/ToolButton.tsx
  25. 9
    0
      src/components/icons.tsx
  26. 21
    0
      src/css/styles.scss
  27. 2
    1
      src/css/theme.scss
  28. 5
    6
      src/data/json.ts
  29. 22
    15
      src/data/library.ts
  30. 33
    1
      src/data/restore.ts
  31. 8
    5
      src/data/types.ts
  32. 15
    0
      src/element/bounds.ts
  33. 1
    0
      src/element/textWysiwyg.tsx
  34. 1
    6
      src/excalidraw-app/collab/RoomDialog.scss
  35. 2
    0
      src/excalidraw-app/collab/RoomDialog.tsx
  36. 2
    0
      src/global.d.ts
  37. 5
    2
      src/i18n.ts
  38. 59
    2
      src/locales/en.json
  39. 2
    2
      src/packages/utils.ts
  40. 2
    2
      src/tests/__snapshots__/contextmenu.test.tsx.snap
  41. 3
    2
      src/tests/contextmenu.test.tsx
  42. 6
    1
      src/tests/library.test.tsx
  43. 18
    1
      src/types.ts
  44. 14
    0
      src/utils.ts

+ 3
- 1
.env View File

@@ -1,6 +1,8 @@
1 1
 REACT_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
2 2
 REACT_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
3 3
 
4
-# dev values
4
+REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
5
+REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
6
+
5 7
 REACT_APP_SOCKET_SERVER_URL=http://localhost:3000
6 8
 REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}'

+ 6
- 1
.env.production View File

@@ -1,6 +1,11 @@
1 1
 REACT_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
2 2
 REACT_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
3 3
 
4
-REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13
4
+REACT_APP_LIBRARY_URL=https://libraries.excalidraw.com
5
+REACT_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries
6
+
5 7
 REACT_APP_SOCKET_SERVER_URL=https://oss-collab-us1.excalidraw.com
6 8
 REACT_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyAd15pYlMci_xIp9ko6wkEsDzAAA0Dn0RU","authDomain":"excalidraw-room-persistence.firebaseapp.com","databaseURL":"https://excalidraw-room-persistence.firebaseio.com","projectId":"excalidraw-room-persistence","storageBucket":"excalidraw-room-persistence.appspot.com","messagingSenderId":"654800341332","appId":"1:654800341332:web:4a692de832b55bd57ce0c1"}'
9
+
10
+# production-only vars
11
+REACT_APP_GOOGLE_ANALYTICS_ID=UA-387204-13

+ 36
- 12
src/actions/actionAddToLibrary.ts View File

@@ -2,22 +2,46 @@ import { register } from "./register";
2 2
 import { getSelectedElements } from "../scene";
3 3
 import { getNonDeletedElements } from "../element";
4 4
 import { deepCopyElement } from "../element/newElement";
5
+import { randomId } from "../random";
6
+import { t } from "../i18n";
5 7
 
6 8
 export const actionAddToLibrary = register({
7 9
   name: "addToLibrary",
8 10
   perform: (elements, appState, _, app) => {
9
-    const selectedElements = getSelectedElements(
10
-      getNonDeletedElements(elements),
11
-      appState,
12
-    );
13
-
14
-    app.library.loadLibrary().then((items) => {
15
-      app.library.saveLibrary([
16
-        ...items,
17
-        selectedElements.map(deepCopyElement),
18
-      ]);
19
-    });
20
-    return false;
11
+    return app.library
12
+      .loadLibrary()
13
+      .then((items) => {
14
+        return app.library.saveLibrary([
15
+          {
16
+            id: randomId(),
17
+            status: "unpublished",
18
+            elements: getSelectedElements(
19
+              getNonDeletedElements(elements),
20
+              appState,
21
+            ).map(deepCopyElement),
22
+            created: Date.now(),
23
+          },
24
+          ...items,
25
+        ]);
26
+      })
27
+      .then(() => {
28
+        return {
29
+          commitToHistory: false,
30
+          appState: {
31
+            ...appState,
32
+            toastMessage: t("toast.addedToLibrary"),
33
+          },
34
+        };
35
+      })
36
+      .catch((error) => {
37
+        return {
38
+          commitToHistory: false,
39
+          appState: {
40
+            ...appState,
41
+            errorMessage: error.message,
42
+          },
43
+        };
44
+      });
21 45
   },
22 46
   contextItemLabel: "labels.addToLibrary",
23 47
 });

+ 1
- 13
src/align.ts View File

@@ -1,13 +1,6 @@
1 1
 import { ExcalidrawElement } from "./element/types";
2 2
 import { newElementWith } from "./element/mutateElement";
3
-import { getCommonBounds } from "./element";
4
-
5
-interface Box {
6
-  minX: number;
7
-  minY: number;
8
-  maxX: number;
9
-  maxY: number;
10
-}
3
+import { Box, getCommonBoundingBox } from "./element/bounds";
11 4
 
12 5
 export interface Alignment {
13 6
   position: "start" | "center" | "end";
@@ -88,8 +81,3 @@ const calculateTranslation = (
88 81
       (groupBoundingBox[min] + groupBoundingBox[max]) / 2,
89 82
   };
90 83
 };
91
-
92
-const getCommonBoundingBox = (elements: ExcalidrawElement[]): Box => {
93
-  const [minX, minY, maxX, maxY] = getCommonBounds(elements);
94
-  return { minX, minY, maxX, maxY };
95
-};

+ 6
- 3
src/components/App.tsx View File

@@ -72,7 +72,7 @@ import {
72 72
 import { loadFromBlob } from "../data";
73 73
 import { isValidLibrary } from "../data/json";
74 74
 import Library from "../data/library";
75
-import { restore, restoreElements } from "../data/restore";
75
+import { restore, restoreElements, restoreLibraryItems } from "../data/restore";
76 76
 import {
77 77
   dragNewElement,
78 78
   dragSelectedElements,
@@ -658,7 +658,7 @@ class App extends React.Component<AppProps, AppState> {
658 658
           t("alerts.confirmAddLibrary", { numShapes: json.library.length }),
659 659
         )
660 660
       ) {
661
-        await this.library.importLibrary(blob);
661
+        await this.library.importLibrary(blob, "published");
662 662
         // hack to rerender the library items after import
663 663
         if (this.state.isLibraryOpen) {
664 664
           this.setState({ isLibraryOpen: false });
@@ -732,7 +732,10 @@ class App extends React.Component<AppProps, AppState> {
732 732
     try {
733 733
       initialData = (await this.props.initialData) || null;
734 734
       if (initialData?.libraryItems) {
735
-        this.libraryItemsFromStorage = initialData.libraryItems;
735
+        this.libraryItemsFromStorage = restoreLibraryItems(
736
+          initialData.libraryItems,
737
+          "unpublished",
738
+        ) as LibraryItems;
736 739
       }
737 740
     } catch (error: any) {
738 741
       console.error(error);

+ 3
- 2
src/components/CheckboxItem.tsx View File

@@ -7,10 +7,11 @@ import "./CheckboxItem.scss";
7 7
 export const CheckboxItem: React.FC<{
8 8
   checked: boolean;
9 9
   onChange: (checked: boolean) => void;
10
-}> = ({ children, checked, onChange }) => {
10
+  className?: string;
11
+}> = ({ children, checked, onChange, className }) => {
11 12
   return (
12 13
     <div
13
-      className={clsx("Checkbox", { "is-checked": checked })}
14
+      className={clsx("Checkbox", className, { "is-checked": checked })}
14 15
       onClick={(event) => {
15 16
         onChange(!checked);
16 17
         (

+ 3
- 0
src/components/Dialog.tsx View File

@@ -18,7 +18,9 @@ export interface DialogProps {
18 18
   title: React.ReactNode;
19 19
   autofocus?: boolean;
20 20
   theme?: AppState["theme"];
21
+  closeOnClickOutside?: boolean;
21 22
 }
23
+
22 24
 export const Dialog = (props: DialogProps) => {
23 25
   const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
24 26
   const [lastActiveElement] = useState(document.activeElement);
@@ -82,6 +84,7 @@ export const Dialog = (props: DialogProps) => {
82 84
       maxWidth={props.small ? 550 : 800}
83 85
       onCloseRequest={onClose}
84 86
       theme={props.theme}
87
+      closeOnClickOutside={props.closeOnClickOutside}
85 88
     >
86 89
       <Island ref={setIslandNode}>
87 90
         <h2 id={`${id}-dialog-title`} className="Dialog__title">

+ 0
- 36
src/components/LayerUI.scss View File

@@ -1,42 +1,6 @@
1 1
 @import "open-color/open-color";
2 2
 
3 3
 .excalidraw {
4
-  .layer-ui__library {
5
-    margin: auto;
6
-    display: flex;
7
-    align-items: center;
8
-    justify-content: center;
9
-
10
-    .layer-ui__library-header {
11
-      display: flex;
12
-      align-items: center;
13
-      width: 100%;
14
-      margin: 2px 0;
15
-
16
-      button {
17
-        // 2px from the left to account for focus border of left-most button
18
-        margin: 0 2px;
19
-      }
20
-
21
-      a {
22
-        margin-inline-start: auto;
23
-        // 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
24
-        padding-inline-end: 18px;
25
-        white-space: nowrap;
26
-      }
27
-    }
28
-  }
29
-
30
-  .layer-ui__library-message {
31
-    padding: 10px 20px;
32
-    max-width: 200px;
33
-  }
34
-
35
-  .layer-ui__library-items {
36
-    max-height: 50vh;
37
-    overflow: auto;
38
-  }
39
-
40 4
   .layer-ui__wrapper {
41 5
     z-index: var(--zIndex-layerUI);
42 6
 

+ 14
- 322
src/components/LayerUI.tsx View File

@@ -1,29 +1,15 @@
1 1
 import clsx from "clsx";
2
-import React, {
3
-  RefObject,
4
-  useCallback,
5
-  useEffect,
6
-  useRef,
7
-  useState,
8
-} from "react";
2
+import React, { useCallback } from "react";
9 3
 import { ActionManager } from "../actions/manager";
10 4
 import { CLASSES } from "../constants";
11 5
 import { exportCanvas } from "../data";
12
-import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
13 6
 import { isTextElement, showSelectedShapeActions } from "../element";
14 7
 import { NonDeletedExcalidrawElement } from "../element/types";
15 8
 import { Language, t } from "../i18n";
16 9
 import { useIsMobile } from "../components/App";
17 10
 import { calculateScrollCenter, getSelectedElements } from "../scene";
18 11
 import { ExportType } from "../scene/types";
19
-import {
20
-  AppProps,
21
-  AppState,
22
-  ExcalidrawProps,
23
-  BinaryFiles,
24
-  LibraryItem,
25
-  LibraryItems,
26
-} from "../types";
12
+import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
27 13
 import { muteFSAbortError } from "../utils";
28 14
 import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
29 15
 import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
@@ -32,10 +18,8 @@ import { ErrorDialog } from "./ErrorDialog";
32 18
 import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
33 19
 import { FixedSideContainer } from "./FixedSideContainer";
34 20
 import { HintViewer } from "./HintViewer";
35
-import { exportFile, load, trash } from "./icons";
36 21
 import { Island } from "./Island";
37 22
 import "./LayerUI.scss";
38
-import { LibraryUnit } from "./LibraryUnit";
39 23
 import { LoadingMessage } from "./LoadingMessage";
40 24
 import { LockButton } from "./LockButton";
41 25
 import { MobileMenu } from "./MobileMenu";
@@ -43,13 +27,13 @@ import { PasteChartDialog } from "./PasteChartDialog";
43 27
 import { Section } from "./Section";
44 28
 import { HelpDialog } from "./HelpDialog";
45 29
 import Stack from "./Stack";
46
-import { ToolButton } from "./ToolButton";
47 30
 import { Tooltip } from "./Tooltip";
48 31
 import { UserList } from "./UserList";
49 32
 import Library from "../data/library";
50 33
 import { JSONExportDialog } from "./JSONExportDialog";
51 34
 import { LibraryButton } from "./LibraryButton";
52 35
 import { isImageFileHandle } from "../data/blob";
36
+import { LibraryMenu } from "./LibraryMenu";
53 37
 
54 38
 interface LayerUIProps {
55 39
   actionManager: ActionManager;
@@ -81,302 +65,6 @@ interface LayerUIProps {
81 65
   onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
82 66
 }
83 67
 
84
-const useOnClickOutside = (
85
-  ref: RefObject<HTMLElement>,
86
-  cb: (event: MouseEvent) => void,
87
-) => {
88
-  useEffect(() => {
89
-    const listener = (event: MouseEvent) => {
90
-      if (!ref.current) {
91
-        return;
92
-      }
93
-
94
-      if (
95
-        event.target instanceof Element &&
96
-        (ref.current.contains(event.target) ||
97
-          !document.body.contains(event.target))
98
-      ) {
99
-        return;
100
-      }
101
-
102
-      cb(event);
103
-    };
104
-    document.addEventListener("pointerdown", listener, false);
105
-
106
-    return () => {
107
-      document.removeEventListener("pointerdown", listener);
108
-    };
109
-  }, [ref, cb]);
110
-};
111
-
112
-const LibraryMenuItems = ({
113
-  libraryItems,
114
-  onRemoveFromLibrary,
115
-  onAddToLibrary,
116
-  onInsertShape,
117
-  pendingElements,
118
-  theme,
119
-  setAppState,
120
-  setLibraryItems,
121
-  libraryReturnUrl,
122
-  focusContainer,
123
-  library,
124
-  files,
125
-  id,
126
-}: {
127
-  libraryItems: LibraryItems;
128
-  pendingElements: LibraryItem;
129
-  onRemoveFromLibrary: (index: number) => void;
130
-  onInsertShape: (elements: LibraryItem) => void;
131
-  onAddToLibrary: (elements: LibraryItem) => void;
132
-  theme: AppState["theme"];
133
-  files: BinaryFiles;
134
-  setAppState: React.Component<any, AppState>["setState"];
135
-  setLibraryItems: (library: LibraryItems) => void;
136
-  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
137
-  focusContainer: () => void;
138
-  library: Library;
139
-  id: string;
140
-}) => {
141
-  const isMobile = useIsMobile();
142
-  const numCells = libraryItems.length + (pendingElements.length > 0 ? 1 : 0);
143
-  const CELLS_PER_ROW = isMobile ? 4 : 6;
144
-  const numRows = Math.max(1, Math.ceil(numCells / CELLS_PER_ROW));
145
-  const rows = [];
146
-  let addedPendingElements = false;
147
-
148
-  const referrer =
149
-    libraryReturnUrl || window.location.origin + window.location.pathname;
150
-
151
-  rows.push(
152
-    <div className="layer-ui__library-header" key="library-header">
153
-      <ToolButton
154
-        key="import"
155
-        type="button"
156
-        title={t("buttons.load")}
157
-        aria-label={t("buttons.load")}
158
-        icon={load}
159
-        onClick={() => {
160
-          importLibraryFromJSON(library)
161
-            .then(() => {
162
-              // Close and then open to get the libraries updated
163
-              setAppState({ isLibraryOpen: false });
164
-              setAppState({ isLibraryOpen: true });
165
-            })
166
-            .catch(muteFSAbortError)
167
-            .catch((error) => {
168
-              setAppState({ errorMessage: error.message });
169
-            });
170
-        }}
171
-      />
172
-      {!!libraryItems.length && (
173
-        <>
174
-          <ToolButton
175
-            key="export"
176
-            type="button"
177
-            title={t("buttons.export")}
178
-            aria-label={t("buttons.export")}
179
-            icon={exportFile}
180
-            onClick={() => {
181
-              saveLibraryAsJSON(library)
182
-                .catch(muteFSAbortError)
183
-                .catch((error) => {
184
-                  setAppState({ errorMessage: error.message });
185
-                });
186
-            }}
187
-          />
188
-          <ToolButton
189
-            key="reset"
190
-            type="button"
191
-            title={t("buttons.resetLibrary")}
192
-            aria-label={t("buttons.resetLibrary")}
193
-            icon={trash}
194
-            onClick={() => {
195
-              if (window.confirm(t("alerts.resetLibrary"))) {
196
-                library.resetLibrary();
197
-                setLibraryItems([]);
198
-                focusContainer();
199
-              }
200
-            }}
201
-          />
202
-        </>
203
-      )}
204
-      <a
205
-        href={`https://libraries.excalidraw.com?target=${
206
-          window.name || "_blank"
207
-        }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
208
-        target="_excalidraw_libraries"
209
-      >
210
-        {t("labels.libraries")}
211
-      </a>
212
-    </div>,
213
-  );
214
-
215
-  for (let row = 0; row < numRows; row++) {
216
-    const y = CELLS_PER_ROW * row;
217
-    const children = [];
218
-    for (let x = 0; x < CELLS_PER_ROW; x++) {
219
-      const shouldAddPendingElements: boolean =
220
-        pendingElements.length > 0 &&
221
-        !addedPendingElements &&
222
-        y + x >= libraryItems.length;
223
-      addedPendingElements = addedPendingElements || shouldAddPendingElements;
224
-
225
-      children.push(
226
-        <Stack.Col key={x}>
227
-          <LibraryUnit
228
-            elements={libraryItems[y + x]}
229
-            files={files}
230
-            pendingElements={
231
-              shouldAddPendingElements ? pendingElements : undefined
232
-            }
233
-            onRemoveFromLibrary={onRemoveFromLibrary.bind(null, y + x)}
234
-            onClick={
235
-              shouldAddPendingElements
236
-                ? onAddToLibrary.bind(null, pendingElements)
237
-                : onInsertShape.bind(null, libraryItems[y + x])
238
-            }
239
-          />
240
-        </Stack.Col>,
241
-      );
242
-    }
243
-    rows.push(
244
-      <Stack.Row align="center" gap={1} key={row}>
245
-        {children}
246
-      </Stack.Row>,
247
-    );
248
-  }
249
-
250
-  return (
251
-    <Stack.Col align="start" gap={1} className="layer-ui__library-items">
252
-      {rows}
253
-    </Stack.Col>
254
-  );
255
-};
256
-
257
-const LibraryMenu = ({
258
-  onClickOutside,
259
-  onInsertShape,
260
-  pendingElements,
261
-  onAddToLibrary,
262
-  theme,
263
-  setAppState,
264
-  files,
265
-  libraryReturnUrl,
266
-  focusContainer,
267
-  library,
268
-  id,
269
-}: {
270
-  pendingElements: LibraryItem;
271
-  onClickOutside: (event: MouseEvent) => void;
272
-  onInsertShape: (elements: LibraryItem) => void;
273
-  onAddToLibrary: () => void;
274
-  theme: AppState["theme"];
275
-  files: BinaryFiles;
276
-  setAppState: React.Component<any, AppState>["setState"];
277
-  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
278
-  focusContainer: () => void;
279
-  library: Library;
280
-  id: string;
281
-}) => {
282
-  const ref = useRef<HTMLDivElement | null>(null);
283
-  useOnClickOutside(ref, (event) => {
284
-    // If click on the library icon, do nothing.
285
-    if ((event.target as Element).closest(".ToolIcon_type_button__library")) {
286
-      return;
287
-    }
288
-    onClickOutside(event);
289
-  });
290
-
291
-  const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
292
-
293
-  const [loadingState, setIsLoading] = useState<
294
-    "preloading" | "loading" | "ready"
295
-  >("preloading");
296
-
297
-  const loadingTimerRef = useRef<number | null>(null);
298
-
299
-  useEffect(() => {
300
-    Promise.race([
301
-      new Promise((resolve) => {
302
-        loadingTimerRef.current = window.setTimeout(() => {
303
-          resolve("loading");
304
-        }, 100);
305
-      }),
306
-      library.loadLibrary().then((items) => {
307
-        setLibraryItems(items);
308
-        setIsLoading("ready");
309
-      }),
310
-    ]).then((data) => {
311
-      if (data === "loading") {
312
-        setIsLoading("loading");
313
-      }
314
-    });
315
-    return () => {
316
-      clearTimeout(loadingTimerRef.current!);
317
-    };
318
-  }, [library]);
319
-
320
-  const removeFromLibrary = useCallback(
321
-    async (indexToRemove) => {
322
-      const items = await library.loadLibrary();
323
-      const nextItems = items.filter((_, index) => index !== indexToRemove);
324
-      library.saveLibrary(nextItems).catch((error) => {
325
-        setLibraryItems(items);
326
-        setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
327
-      });
328
-      setLibraryItems(nextItems);
329
-    },
330
-    [library, setAppState],
331
-  );
332
-
333
-  const addToLibrary = useCallback(
334
-    async (elements: LibraryItem) => {
335
-      if (elements.some((element) => element.type === "image")) {
336
-        return setAppState({
337
-          errorMessage: "Support for adding images to the library coming soon!",
338
-        });
339
-      }
340
-
341
-      const items = await library.loadLibrary();
342
-      const nextItems = [...items, elements];
343
-      onAddToLibrary();
344
-      library.saveLibrary(nextItems).catch((error) => {
345
-        setLibraryItems(items);
346
-        setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
347
-      });
348
-      setLibraryItems(nextItems);
349
-    },
350
-    [onAddToLibrary, library, setAppState],
351
-  );
352
-
353
-  return loadingState === "preloading" ? null : (
354
-    <Island padding={1} ref={ref} className="layer-ui__library">
355
-      {loadingState === "loading" ? (
356
-        <div className="layer-ui__library-message">
357
-          {t("labels.libraryLoadingMessage")}
358
-        </div>
359
-      ) : (
360
-        <LibraryMenuItems
361
-          libraryItems={libraryItems}
362
-          onRemoveFromLibrary={removeFromLibrary}
363
-          onAddToLibrary={addToLibrary}
364
-          onInsertShape={onInsertShape}
365
-          pendingElements={pendingElements}
366
-          setAppState={setAppState}
367
-          setLibraryItems={setLibraryItems}
368
-          libraryReturnUrl={libraryReturnUrl}
369
-          focusContainer={focusContainer}
370
-          library={library}
371
-          theme={theme}
372
-          files={files}
373
-          id={id}
374
-        />
375
-      )}
376
-    </Island>
377
-  );
378
-};
379
-
380 68
 const LayerUI = ({
381 69
   actionManager,
382 70
   appState,
@@ -561,12 +249,15 @@ const LayerUI = ({
561 249
     </Section>
562 250
   );
563 251
 
564
-  const closeLibrary = useCallback(
565
-    (event) => {
566
-      setAppState({ isLibraryOpen: false });
567
-    },
568
-    [setAppState],
569
-  );
252
+  const closeLibrary = useCallback(() => {
253
+    const isDialogOpen = !!document.querySelector(".Dialog");
254
+
255
+    // Prevent closing if any dialog is open
256
+    if (isDialogOpen) {
257
+      return;
258
+    }
259
+    setAppState({ isLibraryOpen: false });
260
+  }, [setAppState]);
570 261
 
571 262
   const deselectItems = useCallback(() => {
572 263
     setAppState({
@@ -578,7 +269,7 @@ const LayerUI = ({
578 269
   const libraryMenu = appState.isLibraryOpen ? (
579 270
     <LibraryMenu
580 271
       pendingElements={getSelectedElements(elements, appState)}
581
-      onClickOutside={closeLibrary}
272
+      onClose={closeLibrary}
582 273
       onInsertShape={onInsertElements}
583 274
       onAddToLibrary={deselectItems}
584 275
       setAppState={setAppState}
@@ -588,6 +279,7 @@ const LayerUI = ({
588 279
       theme={appState.theme}
589 280
       files={files}
590 281
       id={id}
282
+      appState={appState}
591 283
     />
592 284
   ) : null;
593 285
 

+ 55
- 0
src/components/LibraryMenu.scss View File

@@ -0,0 +1,55 @@
1
+@import "open-color/open-color";
2
+
3
+.excalidraw {
4
+  .layer-ui__library {
5
+    margin: auto;
6
+    display: flex;
7
+    align-items: center;
8
+    justify-content: center;
9
+
10
+    .layer-ui__library-header {
11
+      display: flex;
12
+      align-items: center;
13
+      width: 100%;
14
+      margin: 2px 0;
15
+
16
+      button {
17
+        // 2px from the left to account for focus border of left-most button
18
+        margin: 0 2px;
19
+      }
20
+
21
+      a {
22
+        margin-inline-start: auto;
23
+        // 17px for scrollbar (needed for overlay scrollbars on Big Sur?) + 1px extra
24
+        padding-inline-end: 18px;
25
+        white-space: nowrap;
26
+      }
27
+    }
28
+  }
29
+
30
+  .layer-ui__library-message {
31
+    padding: 10px 20px;
32
+    max-width: 200px;
33
+  }
34
+
35
+  .publish-library-success {
36
+    .Dialog__content {
37
+      display: flex;
38
+      flex-direction: column;
39
+    }
40
+
41
+    &-close.ToolIcon_type_button {
42
+      background-color: $oc-blue-6;
43
+      align-self: flex-end;
44
+      &:hover {
45
+        background-color: $oc-blue-8;
46
+      }
47
+      .ToolIcon__icon {
48
+        width: auto;
49
+        font-size: 1rem;
50
+        color: $oc-white;
51
+        padding: 0 0.5rem;
52
+      }
53
+    }
54
+  }
55
+}

+ 287
- 0
src/components/LibraryMenu.tsx View File

@@ -0,0 +1,287 @@
1
+import { useRef, useState, useEffect, useCallback, RefObject } from "react";
2
+import Library from "../data/library";
3
+import { t } from "../i18n";
4
+import { randomId } from "../random";
5
+import {
6
+  LibraryItems,
7
+  LibraryItem,
8
+  AppState,
9
+  BinaryFiles,
10
+  ExcalidrawProps,
11
+} from "../types";
12
+import { Dialog } from "./Dialog";
13
+import { Island } from "./Island";
14
+import PublishLibrary from "./PublishLibrary";
15
+import { ToolButton } from "./ToolButton";
16
+
17
+import "./LibraryMenu.scss";
18
+import LibraryMenuItems from "./LibraryMenuItems";
19
+import { EVENT } from "../constants";
20
+import { KEYS } from "../keys";
21
+
22
+const useOnClickOutside = (
23
+  ref: RefObject<HTMLElement>,
24
+  cb: (event: MouseEvent) => void,
25
+) => {
26
+  useEffect(() => {
27
+    const listener = (event: MouseEvent) => {
28
+      if (!ref.current) {
29
+        return;
30
+      }
31
+
32
+      if (
33
+        event.target instanceof Element &&
34
+        (ref.current.contains(event.target) ||
35
+          !document.body.contains(event.target))
36
+      ) {
37
+        return;
38
+      }
39
+
40
+      cb(event);
41
+    };
42
+    document.addEventListener("pointerdown", listener, false);
43
+
44
+    return () => {
45
+      document.removeEventListener("pointerdown", listener);
46
+    };
47
+  }, [ref, cb]);
48
+};
49
+
50
+const getSelectedItems = (
51
+  libraryItems: LibraryItems,
52
+  selectedItems: LibraryItem["id"][],
53
+) => libraryItems.filter((item) => selectedItems.includes(item.id));
54
+
55
+export const LibraryMenu = ({
56
+  onClose,
57
+  onInsertShape,
58
+  pendingElements,
59
+  onAddToLibrary,
60
+  theme,
61
+  setAppState,
62
+  files,
63
+  libraryReturnUrl,
64
+  focusContainer,
65
+  library,
66
+  id,
67
+  appState,
68
+}: {
69
+  pendingElements: LibraryItem["elements"];
70
+  onClose: () => void;
71
+  onInsertShape: (elements: LibraryItem["elements"]) => void;
72
+  onAddToLibrary: () => void;
73
+  theme: AppState["theme"];
74
+  files: BinaryFiles;
75
+  setAppState: React.Component<any, AppState>["setState"];
76
+  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
77
+  focusContainer: () => void;
78
+  library: Library;
79
+  id: string;
80
+  appState: AppState;
81
+}) => {
82
+  const ref = useRef<HTMLDivElement | null>(null);
83
+
84
+  useOnClickOutside(ref, (event) => {
85
+    // If click on the library icon, do nothing.
86
+    if ((event.target as Element).closest(".ToolIcon__library")) {
87
+      return;
88
+    }
89
+    onClose();
90
+  });
91
+
92
+  useEffect(() => {
93
+    const handleKeyDown = (event: KeyboardEvent) => {
94
+      if (event.key === KEYS.ESCAPE) {
95
+        onClose();
96
+      }
97
+    };
98
+    document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
99
+    return () => {
100
+      document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
101
+    };
102
+  }, [onClose]);
103
+
104
+  const [libraryItems, setLibraryItems] = useState<LibraryItems>([]);
105
+
106
+  const [loadingState, setIsLoading] = useState<
107
+    "preloading" | "loading" | "ready"
108
+  >("preloading");
109
+  const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
110
+  const [showPublishLibraryDialog, setShowPublishLibraryDialog] =
111
+    useState(false);
112
+  const [publishLibSuccess, setPublishLibSuccess] = useState<null | {
113
+    url: string;
114
+    authorName: string;
115
+  }>(null);
116
+  const loadingTimerRef = useRef<number | null>(null);
117
+
118
+  useEffect(() => {
119
+    Promise.race([
120
+      new Promise((resolve) => {
121
+        loadingTimerRef.current = window.setTimeout(() => {
122
+          resolve("loading");
123
+        }, 100);
124
+      }),
125
+      library.loadLibrary().then((items) => {
126
+        setLibraryItems(items);
127
+        setIsLoading("ready");
128
+      }),
129
+    ]).then((data) => {
130
+      if (data === "loading") {
131
+        setIsLoading("loading");
132
+      }
133
+    });
134
+    return () => {
135
+      clearTimeout(loadingTimerRef.current!);
136
+    };
137
+  }, [library]);
138
+
139
+  const removeFromLibrary = useCallback(async () => {
140
+    const items = await library.loadLibrary();
141
+
142
+    const nextItems = items.filter((item) => !selectedItems.includes(item.id));
143
+    library.saveLibrary(nextItems).catch((error) => {
144
+      setLibraryItems(items);
145
+      setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
146
+    });
147
+    setSelectedItems([]);
148
+    setLibraryItems(nextItems);
149
+  }, [library, setAppState, selectedItems, setSelectedItems]);
150
+
151
+  const resetLibrary = useCallback(() => {
152
+    library.resetLibrary();
153
+    setLibraryItems([]);
154
+    focusContainer();
155
+  }, [library, focusContainer]);
156
+
157
+  const addToLibrary = useCallback(
158
+    async (elements: LibraryItem["elements"]) => {
159
+      if (elements.some((element) => element.type === "image")) {
160
+        return setAppState({
161
+          errorMessage: "Support for adding images to the library coming soon!",
162
+        });
163
+      }
164
+      const items = await library.loadLibrary();
165
+      const nextItems: LibraryItems = [
166
+        {
167
+          status: "unpublished",
168
+          elements,
169
+          id: randomId(),
170
+          created: Date.now(),
171
+        },
172
+        ...items,
173
+      ];
174
+      onAddToLibrary();
175
+      library.saveLibrary(nextItems).catch((error) => {
176
+        setLibraryItems(items);
177
+        setAppState({ errorMessage: t("alerts.errorAddingToLibrary") });
178
+      });
179
+      setLibraryItems(nextItems);
180
+    },
181
+    [onAddToLibrary, library, setAppState],
182
+  );
183
+
184
+  const renderPublishSuccess = useCallback(() => {
185
+    return (
186
+      <Dialog
187
+        onCloseRequest={() => setPublishLibSuccess(null)}
188
+        title={t("publishSuccessDialog.title")}
189
+        className="publish-library-success"
190
+        small={true}
191
+      >
192
+        <p>
193
+          {t("publishSuccessDialog.content", {
194
+            authorName: publishLibSuccess!.authorName,
195
+          })}{" "}
196
+          <a
197
+            href={publishLibSuccess?.url}
198
+            target="_blank"
199
+            rel="noopener noreferrer"
200
+          >
201
+            {t("publishSuccessDialog.link")}
202
+          </a>
203
+        </p>
204
+        <ToolButton
205
+          type="button"
206
+          title={t("buttons.close")}
207
+          aria-label={t("buttons.close")}
208
+          label={t("buttons.close")}
209
+          onClick={() => setPublishLibSuccess(null)}
210
+          data-testid="publish-library-success-close"
211
+          className="publish-library-success-close"
212
+        />
213
+      </Dialog>
214
+    );
215
+  }, [setPublishLibSuccess, publishLibSuccess]);
216
+
217
+  const onPublishLibSuccess = useCallback(
218
+    (data) => {
219
+      setShowPublishLibraryDialog(false);
220
+      setPublishLibSuccess({ url: data.url, authorName: data.authorName });
221
+      const nextLibItems = libraryItems.slice();
222
+      nextLibItems.forEach((libItem) => {
223
+        if (selectedItems.includes(libItem.id)) {
224
+          libItem.status = "published";
225
+        }
226
+      });
227
+      library.saveLibrary(nextLibItems);
228
+      setLibraryItems(nextLibItems);
229
+    },
230
+    [
231
+      setShowPublishLibraryDialog,
232
+      setPublishLibSuccess,
233
+      libraryItems,
234
+      selectedItems,
235
+      library,
236
+    ],
237
+  );
238
+
239
+  return loadingState === "preloading" ? null : (
240
+    <Island padding={1} ref={ref} className="layer-ui__library">
241
+      {showPublishLibraryDialog && (
242
+        <PublishLibrary
243
+          onClose={() => setShowPublishLibraryDialog(false)}
244
+          libraryItems={getSelectedItems(libraryItems, selectedItems)}
245
+          appState={appState}
246
+          onSuccess={onPublishLibSuccess}
247
+          onError={(error) => window.alert(error)}
248
+          updateItemsInStorage={() => library.saveLibrary(libraryItems)}
249
+          onRemove={(id: string) =>
250
+            setSelectedItems(selectedItems.filter((_id) => _id !== id))
251
+          }
252
+        />
253
+      )}
254
+      {publishLibSuccess && renderPublishSuccess()}
255
+
256
+      {loadingState === "loading" ? (
257
+        <div className="layer-ui__library-message">
258
+          {t("labels.libraryLoadingMessage")}
259
+        </div>
260
+      ) : (
261
+        <LibraryMenuItems
262
+          libraryItems={libraryItems}
263
+          onRemoveFromLibrary={removeFromLibrary}
264
+          onAddToLibrary={addToLibrary}
265
+          onInsertShape={onInsertShape}
266
+          pendingElements={pendingElements}
267
+          setAppState={setAppState}
268
+          libraryReturnUrl={libraryReturnUrl}
269
+          library={library}
270
+          theme={theme}
271
+          files={files}
272
+          id={id}
273
+          selectedItems={selectedItems}
274
+          onToggle={(id) => {
275
+            if (!selectedItems.includes(id)) {
276
+              setSelectedItems([...selectedItems, id]);
277
+            } else {
278
+              setSelectedItems(selectedItems.filter((_id) => _id !== id));
279
+            }
280
+          }}
281
+          onPublish={() => setShowPublishLibraryDialog(true)}
282
+          resetLibrary={resetLibrary}
283
+        />
284
+      )}
285
+    </Island>
286
+  );
287
+};

+ 102
- 0
src/components/LibraryMenuItems.scss View File

@@ -0,0 +1,102 @@
1
+@import "open-color/open-color";
2
+
3
+.excalidraw {
4
+  .library-menu-items-container {
5
+    .library-actions {
6
+      display: flex;
7
+
8
+      button .library-actions-counter {
9
+        position: absolute;
10
+        right: 2px;
11
+        bottom: 2px;
12
+        border-radius: 50%;
13
+        width: 1em;
14
+        height: 1em;
15
+        padding: 1px;
16
+        font-size: 0.7rem;
17
+        background: #fff;
18
+      }
19
+
20
+      &--remove {
21
+        background-color: $oc-red-7;
22
+        &:hover {
23
+          background-color: $oc-red-8;
24
+        }
25
+        &:active {
26
+          background-color: $oc-red-9;
27
+        }
28
+        svg {
29
+          color: $oc-white;
30
+        }
31
+        .library-actions-counter {
32
+          color: $oc-red-7;
33
+        }
34
+      }
35
+
36
+      &--export {
37
+        background-color: $oc-lime-5;
38
+
39
+        &:hover {
40
+          background-color: $oc-lime-7;
41
+        }
42
+
43
+        &:active {
44
+          background-color: $oc-lime-8;
45
+        }
46
+        svg {
47
+          color: $oc-white;
48
+        }
49
+        .library-actions-counter {
50
+          color: $oc-lime-5;
51
+        }
52
+      }
53
+
54
+      &--publish {
55
+        background-color: $oc-cyan-6;
56
+        &:hover {
57
+          background-color: $oc-cyan-7;
58
+        }
59
+        &:active {
60
+          background-color: $oc-cyan-9;
61
+        }
62
+        svg {
63
+          color: $oc-white;
64
+        }
65
+        label {
66
+          margin-left: -0.2em;
67
+          margin-right: 1.1em;
68
+          color: $oc-white;
69
+          font-size: 0.86em;
70
+        }
71
+        .library-actions-counter {
72
+          color: $oc-cyan-6;
73
+        }
74
+      }
75
+
76
+      &--load {
77
+        background-color: $oc-blue-6;
78
+        &:hover {
79
+          background-color: $oc-blue-7;
80
+        }
81
+        &:active {
82
+          background-color: $oc-blue-9;
83
+        }
84
+        svg {
85
+          color: $oc-white;
86
+        }
87
+      }
88
+    }
89
+    &__items {
90
+      max-height: 50vh;
91
+      overflow: auto;
92
+      margin-top: 0.5rem;
93
+    }
94
+
95
+    .separator {
96
+      font-weight: 500;
97
+      font-size: 0.9rem;
98
+      margin: 0.6em 0.2em;
99
+      color: var(--text-primary-color);
100
+    }
101
+  }
102
+}

+ 322
- 0
src/components/LibraryMenuItems.tsx View File

@@ -0,0 +1,322 @@
1
+import { chunk } from "lodash";
2
+import { useCallback, useState } from "react";
3
+import { importLibraryFromJSON, saveLibraryAsJSON } from "../data/json";
4
+import Library from "../data/library";
5
+import { ExcalidrawElement, NonDeleted } from "../element/types";
6
+import { t } from "../i18n";
7
+import {
8
+  AppState,
9
+  BinaryFiles,
10
+  ExcalidrawProps,
11
+  LibraryItem,
12
+  LibraryItems,
13
+} from "../types";
14
+import { muteFSAbortError } from "../utils";
15
+import { useIsMobile } from "./App";
16
+import ConfirmDialog from "./ConfirmDialog";
17
+import { exportToFileIcon, load, publishIcon, trash } from "./icons";
18
+import { LibraryUnit } from "./LibraryUnit";
19
+import Stack from "./Stack";
20
+import { ToolButton } from "./ToolButton";
21
+import { Tooltip } from "./Tooltip";
22
+
23
+import "./LibraryMenuItems.scss";
24
+
25
+const LibraryMenuItems = ({
26
+  libraryItems,
27
+  onRemoveFromLibrary,
28
+  onAddToLibrary,
29
+  onInsertShape,
30
+  pendingElements,
31
+  theme,
32
+  setAppState,
33
+  libraryReturnUrl,
34
+  library,
35
+  files,
36
+  id,
37
+  selectedItems,
38
+  onToggle,
39
+  onPublish,
40
+  resetLibrary,
41
+}: {
42
+  libraryItems: LibraryItems;
43
+  pendingElements: LibraryItem["elements"];
44
+  onRemoveFromLibrary: () => void;
45
+  onInsertShape: (elements: LibraryItem["elements"]) => void;
46
+  onAddToLibrary: (elements: LibraryItem["elements"]) => void;
47
+  theme: AppState["theme"];
48
+  files: BinaryFiles;
49
+  setAppState: React.Component<any, AppState>["setState"];
50
+  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
51
+  library: Library;
52
+  id: string;
53
+  selectedItems: LibraryItem["id"][];
54
+  onToggle: (id: LibraryItem["id"]) => void;
55
+  onPublish: () => void;
56
+  resetLibrary: () => void;
57
+}) => {
58
+  const renderRemoveLibAlert = useCallback(() => {
59
+    const content = selectedItems.length
60
+      ? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
61
+      : t("alerts.resetLibrary");
62
+    const title = selectedItems.length
63
+      ? t("confirmDialog.removeItemsFromLib")
64
+      : t("confirmDialog.resetLibrary");
65
+    return (
66
+      <ConfirmDialog
67
+        onConfirm={() => {
68
+          if (selectedItems.length) {
69
+            onRemoveFromLibrary();
70
+          } else {
71
+            resetLibrary();
72
+          }
73
+          setShowRemoveLibAlert(false);
74
+        }}
75
+        onCancel={() => {
76
+          setShowRemoveLibAlert(false);
77
+        }}
78
+        title={title}
79
+      >
80
+        <p>{content}</p>
81
+      </ConfirmDialog>
82
+    );
83
+  }, [selectedItems, onRemoveFromLibrary, resetLibrary]);
84
+
85
+  const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false);
86
+
87
+  const isMobile = useIsMobile();
88
+
89
+  const renderLibraryActions = () => {
90
+    const itemsSelected = !!selectedItems.length;
91
+    const items = itemsSelected
92
+      ? libraryItems.filter((item) => selectedItems.includes(item.id))
93
+      : libraryItems;
94
+    const resetLabel = itemsSelected
95
+      ? t("buttons.remove")
96
+      : t("buttons.resetLibrary");
97
+    return (
98
+      <div className="library-actions">
99
+        {(!itemsSelected || !isMobile) && (
100
+          <ToolButton
101
+            key="import"
102
+            type="button"
103
+            title={t("buttons.load")}
104
+            aria-label={t("buttons.load")}
105
+            icon={load}
106
+            onClick={() => {
107
+              importLibraryFromJSON(library)
108
+                .then(() => {
109
+                  // Close and then open to get the libraries updated
110
+                  setAppState({ isLibraryOpen: false });
111
+                  setAppState({ isLibraryOpen: true });
112
+                })
113
+                .catch(muteFSAbortError)
114
+                .catch((error) => {
115
+                  setAppState({ errorMessage: error.message });
116
+                });
117
+            }}
118
+            className="library-actions--load"
119
+          />
120
+        )}
121
+        {!!items.length && (
122
+          <>
123
+            <ToolButton
124
+              key="export"
125
+              type="button"
126
+              title={t("buttons.export")}
127
+              aria-label={t("buttons.export")}
128
+              icon={exportToFileIcon}
129
+              onClick={async () => {
130
+                const libraryItems = itemsSelected
131
+                  ? items
132
+                  : await library.loadLibrary();
133
+                saveLibraryAsJSON(libraryItems)
134
+                  .catch(muteFSAbortError)
135
+                  .catch((error) => {
136
+                    setAppState({ errorMessage: error.message });
137
+                  });
138
+              }}
139
+              className="library-actions--export"
140
+            >
141
+              {selectedItems.length > 0 && (
142
+                <span className="library-actions-counter">
143
+                  {selectedItems.length}
144
+                </span>
145
+              )}
146
+            </ToolButton>
147
+            <ToolButton
148
+              key="reset"
149
+              type="button"
150
+              title={resetLabel}
151
+              aria-label={resetLabel}
152
+              icon={trash}
153
+              onClick={() => setShowRemoveLibAlert(true)}
154
+              className="library-actions--remove"
155
+            >
156
+              {selectedItems.length > 0 && (
157
+                <span className="library-actions-counter">
158
+                  {selectedItems.length}
159
+                </span>
160
+              )}
161
+            </ToolButton>
162
+          </>
163
+        )}
164
+        {itemsSelected && !isPublished && (
165
+          <Tooltip label={t("hints.publishLibrary")}>
166
+            <ToolButton
167
+              type="button"
168
+              aria-label={t("buttons.publishLibrary")}
169
+              label={t("buttons.publishLibrary")}
170
+              icon={publishIcon}
171
+              className="library-actions--publish"
172
+              onClick={onPublish}
173
+            >
174
+              {!isMobile && <label>{t("buttons.publishLibrary")}</label>}
175
+              {selectedItems.length > 0 && (
176
+                <span className="library-actions-counter">
177
+                  {selectedItems.length}
178
+                </span>
179
+              )}
180
+            </ToolButton>
181
+          </Tooltip>
182
+        )}
183
+      </div>
184
+    );
185
+  };
186
+
187
+  const CELLS_PER_ROW = isMobile ? 4 : 6;
188
+
189
+  const referrer =
190
+    libraryReturnUrl || window.location.origin + window.location.pathname;
191
+  const isPublished = selectedItems.some(
192
+    (id) => libraryItems.find((item) => item.id === id)?.status === "published",
193
+  );
194
+
195
+  const createLibraryItemCompo = (params: {
196
+    item:
197
+      | LibraryItem
198
+      | /* pending library item */ {
199
+          id: null;
200
+          elements: readonly NonDeleted<ExcalidrawElement>[];
201
+        }
202
+      | null;
203
+    onClick?: () => void;
204
+    key: string;
205
+  }) => {
206
+    return (
207
+      <Stack.Col key={params.key}>
208
+        <LibraryUnit
209
+          elements={params.item?.elements}
210
+          files={files}
211
+          isPending={!params.item?.id && !!params.item?.elements}
212
+          onClick={params.onClick || (() => {})}
213
+          id={params.item?.id || null}
214
+          selected={!!params.item?.id && selectedItems.includes(params.item.id)}
215
+          onToggle={() => {
216
+            if (params.item?.id) {
217
+              onToggle(params.item.id);
218
+            }
219
+          }}
220
+        />
221
+      </Stack.Col>
222
+    );
223
+  };
224
+
225
+  const renderLibrarySection = (
226
+    items: (
227
+      | LibraryItem
228
+      | /* pending library item */ {
229
+          id: null;
230
+          elements: readonly NonDeleted<ExcalidrawElement>[];
231
+        }
232
+    )[],
233
+  ) => {
234
+    const _items = items.map((item) => {
235
+      if (item.id) {
236
+        return createLibraryItemCompo({
237
+          item,
238
+          onClick: () => onInsertShape(item.elements),
239
+          key: item.id,
240
+        });
241
+      }
242
+      return createLibraryItemCompo({
243
+        key: "__pending__item__",
244
+        item,
245
+        onClick: () => onAddToLibrary(pendingElements),
246
+      });
247
+    });
248
+
249
+    // ensure we render all empty cells if no items are present
250
+    let rows = chunk(_items, CELLS_PER_ROW);
251
+    if (!rows.length) {
252
+      rows = [[]];
253
+    }
254
+
255
+    return rows.map((rowItems, index, rows) => {
256
+      if (index === rows.length - 1) {
257
+        // pad row with empty cells
258
+        rowItems = rowItems.concat(
259
+          new Array(CELLS_PER_ROW - rowItems.length)
260
+            .fill(null)
261
+            .map((_, index) => {
262
+              return createLibraryItemCompo({
263
+                key: `empty_${index}`,
264
+                item: null,
265
+              });
266
+            }),
267
+        );
268
+      }
269
+      return (
270
+        <Stack.Row align="center" gap={1} key={index}>
271
+          {rowItems}
272
+        </Stack.Row>
273
+      );
274
+    });
275
+  };
276
+
277
+  const publishedItems = libraryItems.filter(
278
+    (item) => item.status === "published",
279
+  );
280
+  const unpublishedItems = [
281
+    // append pending library item
282
+    ...(pendingElements.length
283
+      ? [{ id: null, elements: pendingElements }]
284
+      : []),
285
+    ...libraryItems.filter((item) => item.status !== "published"),
286
+  ];
287
+
288
+  return (
289
+    <div className="library-menu-items-container">
290
+      {showRemoveLibAlert && renderRemoveLibAlert()}
291
+      <div className="layer-ui__library-header" key="library-header">
292
+        {renderLibraryActions()}
293
+        <a
294
+          href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
295
+            window.name || "_blank"
296
+          }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}`}
297
+          target="_excalidraw_libraries"
298
+        >
299
+          {t("labels.libraries")}
300
+        </a>
301
+      </div>
302
+      <Stack.Col
303
+        className="library-menu-items-container__items"
304
+        align="start"
305
+        gap={1}
306
+      >
307
+        <>
308
+          <div className="separator">{t("labels.personalLib")}</div>
309
+          {renderLibrarySection(unpublishedItems)}
310
+        </>
311
+
312
+        <>
313
+          <div className="separator">{t("labels.excalidrawLib")} </div>
314
+
315
+          {renderLibrarySection(publishedItems)}
316
+        </>
317
+      </Stack.Col>
318
+    </div>
319
+  );
320
+};
321
+
322
+export default LibraryMenuItems;

+ 59
- 15
src/components/LibraryUnit.scss View File

@@ -1,3 +1,5 @@
1
+@import "../css/variables.module";
2
+
1 3
 .excalidraw {
2 4
   .library-unit {
3 5
     align-items: center;
@@ -7,6 +9,20 @@
7 9
     position: relative;
8 10
     width: 63px;
9 11
     height: 63px; // match width
12
+
13
+    &--hover {
14
+      box-shadow: inset 0px 0px 0px 2px $oc-blue-5;
15
+      border-color: $oc-blue-5;
16
+    }
17
+
18
+    &--selected {
19
+      box-shadow: inset 0px 0px 0px 2px $oc-blue-8;
20
+      border-color: $oc-blue-8;
21
+    }
22
+  }
23
+
24
+  &.theme--dark .library-unit {
25
+    border-color: rgb(48, 48, 48);
10 26
   }
11 27
 
12 28
   .library-unit__dragger {
@@ -22,9 +38,9 @@
22 38
     max-width: 100%;
23 39
   }
24 40
 
25
-  .library-unit__removeFromLibrary,
26
-  .library-unit__removeFromLibrary:hover,
27
-  .library-unit__removeFromLibrary:active {
41
+  .library-unit__checkbox-container,
42
+  .library-unit__checkbox-container:hover,
43
+  .library-unit__checkbox-container:active {
28 44
     align-items: center;
29 45
     background: none;
30 46
     border: none;
@@ -32,10 +48,35 @@
32 48
     display: flex;
33 49
     justify-content: center;
34 50
     margin: 0;
35
-    padding: 0;
51
+    padding: 0.5rem;
36 52
     position: absolute;
37
-    right: 5px;
38
-    top: 5px;
53
+    left: 2rem;
54
+    bottom: 2rem;
55
+    cursor: pointer;
56
+
57
+    input {
58
+      cursor: pointer;
59
+    }
60
+  }
61
+
62
+  .library-unit__checkbox {
63
+    position: absolute;
64
+    left: 2.3rem;
65
+    bottom: 2.3rem;
66
+
67
+    .Checkbox-box {
68
+      width: 13px;
69
+      height: 13px;
70
+      border-radius: 2px;
71
+      margin: 0.5em 0.5em 0.2em 0.2em;
72
+      background-color: $oc-blue-1;
73
+    }
74
+
75
+    &.Checkbox:hover {
76
+      .Checkbox-box {
77
+        background-color: $oc-blue-2;
78
+      }
79
+    }
39 80
   }
40 81
 
41 82
   .library-unit__removeFromLibrary > svg {
@@ -43,29 +84,32 @@
43 84
     width: 16px;
44 85
   }
45 86
 
46
-  .library-unit__pulse {
87
+  .library-unit__adder {
47 88
     transform: scale(1);
48
-    animation: library-unit__pulse-animation 1s ease-in infinite;
89
+    animation: library-unit__adder-animation 1s ease-in infinite;
49 90
   }
50 91
 
51 92
   .library-unit__adder {
52 93
     position: absolute;
53
-    left: 50%;
54
-    top: 50%;
55
-    width: 20px;
56
-    height: 20px;
94
+    left: 40%;
95
+    top: 40%;
96
+    width: 2rem;
97
+    height: 2rem;
57 98
     margin-left: -10px;
58 99
     margin-top: -10px;
59 100
     pointer-events: none;
60 101
   }
102
+  .library-unit--hover .library-unit__adder {
103
+    color: $oc-blue-7;
104
+  }
61 105
 
62 106
   .library-unit__active {
63 107
     cursor: pointer;
64 108
   }
65 109
 
66
-  @keyframes library-unit__pulse-animation {
110
+  @keyframes library-unit__adder-animation {
67 111
     0% {
68
-      transform: scale(0.95);
112
+      transform: scale(0.85);
69 113
     }
70 114
 
71 115
     50% {
@@ -73,7 +117,7 @@
73 117
     }
74 118
 
75 119
     100% {
76
-      transform: scale(0.95);
120
+      transform: scale(0.85);
77 121
     }
78 122
   }
79 123
 }

+ 25
- 24
src/components/LibraryUnit.tsx View File

@@ -1,13 +1,12 @@
1 1
 import clsx from "clsx";
2 2
 import oc from "open-color";
3 3
 import { useEffect, useRef, useState } from "react";
4
-import { close } from "../components/icons";
5 4
 import { MIME_TYPES } from "../constants";
6
-import { t } from "../i18n";
7 5
 import { useIsMobile } from "../components/App";
8 6
 import { exportToSvg } from "../scene/export";
9 7
 import { BinaryFiles, LibraryItem } from "../types";
10 8
 import "./LibraryUnit.scss";
9
+import { CheckboxItem } from "./CheckboxItem";
11 10
 
12 11
 // fa-plus
13 12
 const PLUS_ICON = (
@@ -20,17 +19,21 @@ const PLUS_ICON = (
20 19
 );
21 20
 
22 21
 export const LibraryUnit = ({
22
+  id,
23 23
   elements,
24 24
   files,
25
-  pendingElements,
26
-  onRemoveFromLibrary,
25
+  isPending,
27 26
   onClick,
27
+  selected,
28
+  onToggle,
28 29
 }: {
29
-  elements?: LibraryItem;
30
+  id: LibraryItem["id"] | /** for pending item */ null;
31
+  elements?: LibraryItem["elements"];
30 32
   files: BinaryFiles;
31
-  pendingElements?: LibraryItem;
32
-  onRemoveFromLibrary: () => void;
33
+  isPending?: boolean;
33 34
   onClick: () => void;
35
+  selected: boolean;
36
+  onToggle: (id: string) => void;
34 37
 }) => {
35 38
   const ref = useRef<HTMLDivElement | null>(null);
36 39
   useEffect(() => {
@@ -40,12 +43,11 @@ export const LibraryUnit = ({
40 43
     }
41 44
 
42 45
     (async () => {
43
-      const elementsToRender = elements || pendingElements;
44
-      if (!elementsToRender) {
46
+      if (!elements) {
45 47
         return;
46 48
       }
47 49
       const svg = await exportToSvg(
48
-        elementsToRender,
50
+        elements,
49 51
         {
50 52
           exportBackground: false,
51 53
           viewBackgroundColor: oc.white,
@@ -58,30 +60,31 @@ export const LibraryUnit = ({
58 60
     return () => {
59 61
       node.innerHTML = "";
60 62
     };
61
-  }, [elements, pendingElements, files]);
63
+  }, [elements, files]);
62 64
 
63 65
   const [isHovered, setIsHovered] = useState(false);
64 66
   const isMobile = useIsMobile();
65
-
66
-  const adder = (isHovered || isMobile) && pendingElements && (
67
+  const adder = isPending && (
67 68
     <div className="library-unit__adder">{PLUS_ICON}</div>
68 69
   );
69 70
 
70 71
   return (
71 72
     <div
72 73
       className={clsx("library-unit", {
73
-        "library-unit__active": elements || pendingElements,
74
+        "library-unit__active": elements,
75
+        "library-unit--hover": elements && isHovered,
76
+        "library-unit--selected": selected,
74 77
       })}
75 78
       onMouseEnter={() => setIsHovered(true)}
76 79
       onMouseLeave={() => setIsHovered(false)}
77 80
     >
78 81
       <div
79 82
         className={clsx("library-unit__dragger", {
80
-          "library-unit__pulse": !!pendingElements,
83
+          "library-unit__pulse": !!isPending,
81 84
         })}
82 85
         ref={ref}
83 86
         draggable={!!elements}
84
-        onClick={!!elements || !!pendingElements ? onClick : undefined}
87
+        onClick={!!elements || !!isPending ? onClick : undefined}
85 88
         onDragStart={(event) => {
86 89
           setIsHovered(false);
87 90
           event.dataTransfer.setData(
@@ -91,14 +94,12 @@ export const LibraryUnit = ({
91 94
         }}
92 95
       />
93 96
       {adder}
94
-      {elements && (isHovered || isMobile) && (
95
-        <button
96
-          className="library-unit__removeFromLibrary"
97
-          aria-label={t("labels.removeFromLibrary")}
98
-          onClick={onRemoveFromLibrary}
99
-        >
100
-          {close}
101
-        </button>
97
+      {id && elements && (isHovered || isMobile || selected) && (
98
+        <CheckboxItem
99
+          checked={selected}
100
+          onChange={() => onToggle(id)}
101
+          className="library-unit__checkbox"
102
+        />
102 103
       )}
103 104
     </div>
104 105
   );

+ 6
- 2
src/components/Modal.tsx View File

@@ -15,8 +15,9 @@ export const Modal = (props: {
15 15
   onCloseRequest(): void;
16 16
   labelledBy: string;
17 17
   theme?: AppState["theme"];
18
+  closeOnClickOutside?: boolean;
18 19
 }) => {
19
-  const { theme = THEME.LIGHT } = props;
20
+  const { theme = THEME.LIGHT, closeOnClickOutside = true } = props;
20 21
   const modalRoot = useBodyRoot(theme);
21 22
 
22 23
   if (!modalRoot) {
@@ -39,7 +40,10 @@ export const Modal = (props: {
39 40
       onKeyDown={handleKeydown}
40 41
       aria-labelledby={props.labelledBy}
41 42
     >
42
-      <div className="Modal__background" onClick={props.onCloseRequest}></div>
43
+      <div
44
+        className="Modal__background"
45
+        onClick={closeOnClickOutside ? props.onCloseRequest : undefined}
46
+      ></div>
43 47
       <div
44 48
         className="Modal__content"
45 49
         style={{ "--max-width": `${props.maxWidth}px` }}

+ 1
- 1
src/components/PasteChartDialog.tsx View File

@@ -82,7 +82,7 @@ export const PasteChartDialog = ({
82 82
   appState: AppState;
83 83
   onClose: () => void;
84 84
   setAppState: React.Component<any, AppState>["setState"];
85
-  onInsertChart: (elements: LibraryItem) => void;
85
+  onInsertChart: (elements: LibraryItem["elements"]) => void;
86 86
 }) => {
87 87
   const handleClose = React.useCallback(() => {
88 88
     if (onClose) {

+ 1
- 0
src/components/ProjectName.tsx View File

@@ -42,6 +42,7 @@ export const ProjectName = (props: Props) => {
42 42
       </label>
43 43
       {props.isNameEditable ? (
44 44
         <input
45
+          type="text"
45 46
           className="TextInput"
46 47
           onBlur={handleBlur}
47 48
           onKeyDown={handleKeyDown}

+ 92
- 0
src/components/PublishLibrary.scss View File

@@ -0,0 +1,92 @@
1
+@import "../css/variables.module";
2
+
3
+.excalidraw {
4
+  .publish-library {
5
+    &__fields {
6
+      display: flex;
7
+      flex-direction: column;
8
+
9
+      label {
10
+        padding: 1em;
11
+        display: flex;
12
+        justify-content: space-between;
13
+        align-items: center;
14
+        span {
15
+          font-weight: 500;
16
+          font-size: 1rem;
17
+          color: $oc-gray-6;
18
+        }
19
+        input,
20
+        textarea {
21
+          width: 70%;
22
+          padding: 0.6em;
23
+          font-family: var(--ui-font);
24
+        }
25
+
26
+        .required {
27
+          color: $oc-red-8;
28
+          margin: 0.2rem;
29
+        }
30
+      }
31
+    }
32
+
33
+    &__buttons {
34
+      display: flex;
35
+      padding: 0.2rem 0;
36
+      justify-content: flex-end;
37
+
38
+      .ToolIcon__icon {
39
+        min-width: 2.5rem;
40
+        width: auto;
41
+        font-size: 1rem;
42
+      }
43
+
44
+      .ToolIcon_type_button {
45
+        margin-left: 1rem;
46
+        padding: 0 0.5rem;
47
+      }
48
+
49
+      &--confirm.ToolIcon_type_button {
50
+        background-color: $oc-blue-6;
51
+
52
+        &:hover {
53
+          background-color: $oc-blue-8;
54
+        }
55
+      }
56
+
57
+      &--cancel.ToolIcon_type_button {
58
+        background-color: $oc-gray-5;
59
+        &:hover {
60
+          background-color: $oc-gray-6;
61
+        }
62
+      }
63
+
64
+      .ToolIcon__icon {
65
+        color: $oc-white;
66
+        .Spinner {
67
+          --spinner-color: #fff;
68
+          svg {
69
+            padding: 0.5rem;
70
+          }
71
+        }
72
+      }
73
+    }
74
+
75
+    .selected-library-items {
76
+      display: flex;
77
+      padding: 0 0.8rem;
78
+      flex-wrap: wrap;
79
+
80
+      .single-library-item-wrapper {
81
+        width: 9rem;
82
+      }
83
+    }
84
+
85
+    &-note {
86
+      padding: 1em;
87
+      font-style: italic;
88
+      font-size: 14px;
89
+      display: block;
90
+    }
91
+  }
92
+}

+ 430
- 0
src/components/PublishLibrary.tsx View File

@@ -0,0 +1,430 @@
1
+import { ReactNode, useCallback, useEffect, useState } from "react";
2
+import oc from "open-color";
3
+
4
+import { Dialog } from "./Dialog";
5
+import { t } from "../i18n";
6
+
7
+import { ToolButton } from "./ToolButton";
8
+
9
+import { AppState, LibraryItems, LibraryItem } from "../types";
10
+import { exportToBlob } from "../packages/utils";
11
+import { EXPORT_DATA_TYPES, EXPORT_SOURCE } from "../constants";
12
+import { ExportedLibraryData } from "../data/types";
13
+
14
+import "./PublishLibrary.scss";
15
+import { ExcalidrawElement } from "../element/types";
16
+import { newElement } from "../element";
17
+import { mutateElement } from "../element/mutateElement";
18
+import { getCommonBoundingBox } from "../element/bounds";
19
+import SingleLibraryItem from "./SingleLibraryItem";
20
+
21
+interface PublishLibraryDataParams {
22
+  authorName: string;
23
+  githubHandle: string;
24
+  name: string;
25
+  description: string;
26
+  twitterHandle: string;
27
+  website: string;
28
+}
29
+
30
+const LOCAL_STORAGE_KEY_PUBLISH_LIBRARY = "publish-library-data";
31
+
32
+const savePublishLibDataToStorage = (data: PublishLibraryDataParams) => {
33
+  try {
34
+    localStorage.setItem(
35
+      LOCAL_STORAGE_KEY_PUBLISH_LIBRARY,
36
+      JSON.stringify(data),
37
+    );
38
+  } catch (error: any) {
39
+    // Unable to access window.localStorage
40
+    console.error(error);
41
+  }
42
+};
43
+
44
+const importPublishLibDataFromStorage = () => {
45
+  try {
46
+    const data = localStorage.getItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
47
+    if (data) {
48
+      return JSON.parse(data);
49
+    }
50
+  } catch (error: any) {
51
+    // Unable to access localStorage
52
+    console.error(error);
53
+  }
54
+
55
+  return null;
56
+};
57
+
58
+const PublishLibrary = ({
59
+  onClose,
60
+  libraryItems,
61
+  appState,
62
+  onSuccess,
63
+  onError,
64
+  updateItemsInStorage,
65
+  onRemove,
66
+}: {
67
+  onClose: () => void;
68
+  libraryItems: LibraryItems;
69
+  appState: AppState;
70
+  onSuccess: (data: {
71
+    url: string;
72
+    authorName: string;
73
+    items: LibraryItems;
74
+  }) => void;
75
+
76
+  onError: (error: Error) => void;
77
+  updateItemsInStorage: (items: LibraryItems) => void;
78
+  onRemove: (id: string) => void;
79
+}) => {
80
+  const [libraryData, setLibraryData] = useState<PublishLibraryDataParams>({
81
+    authorName: "",
82
+    githubHandle: "",
83
+    name: "",
84
+    description: "",
85
+    twitterHandle: "",
86
+    website: "",
87
+  });
88
+
89
+  const [isSubmitting, setIsSubmitting] = useState(false);
90
+
91
+  useEffect(() => {
92
+    const data = importPublishLibDataFromStorage();
93
+    if (data) {
94
+      setLibraryData(data);
95
+    }
96
+  }, []);
97
+
98
+  const [clonedLibItems, setClonedLibItems] = useState<LibraryItems>(
99
+    libraryItems.slice(),
100
+  );
101
+
102
+  useEffect(() => {
103
+    setClonedLibItems(libraryItems.slice());
104
+  }, [libraryItems]);
105
+
106
+  const onInputChange = (event: any) => {
107
+    setLibraryData({
108
+      ...libraryData,
109
+      [event.target.name]: event.target.value,
110
+    });
111
+  };
112
+
113
+  const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
114
+    event.preventDefault();
115
+    setIsSubmitting(true);
116
+    const erroredLibItems: LibraryItem[] = [];
117
+    let isError = false;
118
+    clonedLibItems.forEach((libItem) => {
119
+      let error = "";
120
+      if (!libItem.name) {
121
+        error = t("publishDialog.errors.required");
122
+        isError = true;
123
+      } else if (!/^[a-zA-Z\s]+$/i.test(libItem.name)) {
124
+        error = t("publishDialog.errors.letter&Spaces");
125
+        isError = true;
126
+      }
127
+      erroredLibItems.push({ ...libItem, error });
128
+    });
129
+
130
+    if (isError) {
131
+      setClonedLibItems(erroredLibItems);
132
+      setIsSubmitting(false);
133
+      return;
134
+    }
135
+    const elements: ExcalidrawElement[] = [];
136
+    const prevBoundingBox = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
137
+    clonedLibItems.forEach((libItem) => {
138
+      const boundingBox = getCommonBoundingBox(libItem.elements);
139
+      const width = boundingBox.maxX - boundingBox.minX + 30;
140
+      const height = boundingBox.maxY - boundingBox.minY + 30;
141
+      const offset = {
142
+        x: prevBoundingBox.maxX - boundingBox.minX,
143
+        y: prevBoundingBox.maxY - boundingBox.minY,
144
+      };
145
+
146
+      const itemsWithUpdatedCoords = libItem.elements.map((element) => {
147
+        element = mutateElement(element, {
148
+          x: element.x + offset.x + 15,
149
+          y: element.y + offset.y + 15,
150
+        });
151
+        return element;
152
+      });
153
+      const items = [
154
+        ...itemsWithUpdatedCoords,
155
+        newElement({
156
+          type: "rectangle",
157
+          width,
158
+          height,
159
+          x: prevBoundingBox.maxX,
160
+          y: prevBoundingBox.maxY,
161
+          strokeColor: "#ced4da",
162
+          backgroundColor: "transparent",
163
+          strokeStyle: "solid",
164
+          opacity: 100,
165
+          roughness: 0,
166
+          strokeSharpness: "sharp",
167
+          fillStyle: "solid",
168
+          strokeWidth: 1,
169
+        }),
170
+      ];
171
+      elements.push(...items);
172
+      prevBoundingBox.maxX = prevBoundingBox.maxX + width + 30;
173
+    });
174
+    const png = await exportToBlob({
175
+      elements,
176
+      mimeType: "image/png",
177
+      appState: {
178
+        ...appState,
179
+        viewBackgroundColor: oc.white,
180
+        exportBackground: true,
181
+      },
182
+      files: null,
183
+    });
184
+
185
+    const libContent: ExportedLibraryData = {
186
+      type: EXPORT_DATA_TYPES.excalidrawLibrary,
187
+      version: 2,
188
+      source: EXPORT_SOURCE,
189
+      libraryItems: clonedLibItems,
190
+    };
191
+    const content = JSON.stringify(libContent, null, 2);
192
+    const lib = new Blob([content], { type: "application/json" });
193
+
194
+    const formData = new FormData();
195
+    formData.append("excalidrawLib", lib);
196
+    formData.append("excalidrawPng", png!);
197
+    formData.append("title", libraryData.name);
198
+    formData.append("authorName", libraryData.authorName);
199
+    formData.append("githubHandle", libraryData.githubHandle);
200
+    formData.append("name", libraryData.name);
201
+    formData.append("description", libraryData.description);
202
+    formData.append("twitterHandle", libraryData.twitterHandle);
203
+    formData.append("website", libraryData.website);
204
+
205
+    fetch(`${process.env.REACT_APP_LIBRARY_BACKEND}/submit`, {
206
+      method: "post",
207
+      body: formData,
208
+    })
209
+      .then(
210
+        (response) => {
211
+          if (response.ok) {
212
+            return response.json().then(({ url }) => {
213
+              // flush data from local storage
214
+              localStorage.removeItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY);
215
+              onSuccess({
216
+                url,
217
+                authorName: libraryData.authorName,
218
+                items: clonedLibItems,
219
+              });
220
+            });
221
+          }
222
+          return response
223
+            .json()
224
+            .catch(() => {
225
+              throw new Error(response.statusText || "something went wrong");
226
+            })
227
+            .then((error) => {
228
+              throw new Error(
229
+                error.message || response.statusText || "something went wrong",
230
+              );
231
+            });
232
+        },
233
+        (err) => {
234
+          console.error(err);
235
+          onError(err);
236
+          setIsSubmitting(false);
237
+        },
238
+      )
239
+      .catch((err) => {
240
+        console.error(err);
241
+        onError(err);
242
+        setIsSubmitting(false);
243
+      });
244
+  };
245
+
246
+  const renderLibraryItems = () => {
247
+    const items: ReactNode[] = [];
248
+    clonedLibItems.forEach((libItem, index) => {
249
+      items.push(
250
+        <div className="single-library-item-wrapper" key={index}>
251
+          <SingleLibraryItem
252
+            libItem={libItem}
253
+            appState={appState}
254
+            index={index}
255
+            onChange={(val, index) => {
256
+              const items = clonedLibItems.slice();
257
+              items[index].name = val;
258
+              setClonedLibItems(items);
259
+            }}
260
+            onRemove={onRemove}
261
+          />
262
+        </div>,
263
+      );
264
+    });
265
+    return <div className="selected-library-items">{items}</div>;
266
+  };
267
+
268
+  const onDialogClose = useCallback(() => {
269
+    updateItemsInStorage(clonedLibItems);
270
+    savePublishLibDataToStorage(libraryData);
271
+    onClose();
272
+  }, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);
273
+
274
+  const shouldRenderForm = !!libraryItems.length;
275
+  return (
276
+    <Dialog
277
+      onCloseRequest={onDialogClose}
278
+      title={t("publishDialog.title")}
279
+      className="publish-library"
280
+    >
281
+      {shouldRenderForm ? (
282
+        <form onSubmit={onSubmit}>
283
+          <div className="publish-library-note">
284
+            {t("publishDialog.noteDescription.pre")}
285
+            <a
286
+              href="https://libraries.excalidraw.com"
287
+              target="_blank"
288
+              rel="noopener noreferrer"
289
+            >
290
+              {t("publishDialog.noteDescription.link")}
291
+            </a>{" "}
292
+            {t("publishDialog.noteDescription.post")}
293
+          </div>
294
+          <span className="publish-library-note">
295
+            {t("publishDialog.noteGuidelines.pre")}
296
+            <a
297
+              href="https://github.com/excalidraw/excalidraw-libraries#guidelines"
298
+              target="_blank"
299
+              rel="noopener noreferrer"
300
+            >
301
+              {t("publishDialog.noteGuidelines.link")}
302
+            </a>
303
+            {t("publishDialog.noteGuidelines.post")}
304
+          </span>
305
+
306
+          <div className="publish-library-note">
307
+            {t("publishDialog.noteItems")}
308
+          </div>
309
+          {renderLibraryItems()}
310
+          <div className="publish-library__fields">
311
+            <label>
312
+              <div>
313
+                <span>{t("publishDialog.libraryName")}</span>
314
+                <span aria-hidden="true" className="required">
315
+                  *
316
+                </span>
317
+              </div>
318
+              <input
319
+                type="text"
320
+                name="name"
321
+                required
322
+                value={libraryData.name}
323
+                onChange={onInputChange}
324
+                placeholder={t("publishDialog.placeholder.libraryName")}
325
+              />
326
+            </label>
327
+            <label style={{ alignItems: "flex-start" }}>
328
+              <div>
329
+                <span>{t("publishDialog.libraryDesc")}</span>
330
+                <span aria-hidden="true" className="required">
331
+                  *
332
+                </span>
333
+              </div>
334
+              <textarea
335
+                name="description"
336
+                rows={4}
337
+                required
338
+                value={libraryData.description}
339
+                onChange={onInputChange}
340
+                placeholder={t("publishDialog.placeholder.libraryDesc")}
341
+              />
342
+            </label>
343
+            <label>
344
+              <div>
345
+                <span>{t("publishDialog.authorName")}</span>
346
+                <span aria-hidden="true" className="required">
347
+                  *
348
+                </span>
349
+              </div>
350
+              <input
351
+                type="text"
352
+                name="authorName"
353
+                required
354
+                value={libraryData.authorName}
355
+                onChange={onInputChange}
356
+                placeholder={t("publishDialog.placeholder.authorName")}
357
+              />
358
+            </label>
359
+            <label>
360
+              <span>{t("publishDialog.githubUsername")}</span>
361
+              <input
362
+                type="text"
363
+                name="githubHandle"
364
+                value={libraryData.githubHandle}
365
+                onChange={onInputChange}
366
+                placeholder={t("publishDialog.placeholder.githubHandle")}
367
+              />
368
+            </label>
369
+            <label>
370
+              <span>{t("publishDialog.twitterUsername")}</span>
371
+              <input
372
+                type="text"
373
+                name="twitterHandle"
374
+                value={libraryData.twitterHandle}
375
+                onChange={onInputChange}
376
+                placeholder={t("publishDialog.placeholder.twitterHandle")}
377
+              />
378
+            </label>
379
+            <label>
380
+              <span>{t("publishDialog.website")}</span>
381
+              <input
382
+                type="text"
383
+                name="website"
384
+                value={libraryData.website}
385
+                onChange={onInputChange}
386
+                placeholder={t("publishDialog.placeholder.website")}
387
+              />
388
+            </label>
389
+            <span className="publish-library-note">
390
+              {t("publishDialog.noteLicense.pre")}
391
+              <a
392
+                href="https://github.com/excalidraw/excalidraw-libraries/blob/main/LICENSE"
393
+                target="_blank"
394
+                rel="noopener noreferrer"
395
+              >
396
+                {t("publishDialog.noteLicense.link")}
397
+              </a>
398
+              {t("publishDialog.noteLicense.post")}
399
+            </span>
400
+          </div>
401
+          <div className="publish-library__buttons">
402
+            <ToolButton
403
+              type="button"
404
+              title={t("buttons.cancel")}
405
+              aria-label={t("buttons.cancel")}
406
+              label={t("buttons.cancel")}
407
+              onClick={onDialogClose}
408
+              data-testid="cancel-clear-canvas-button"
409
+              className="publish-library__buttons--cancel"
410
+            />
411
+            <ToolButton
412
+              type="submit"
413
+              title={t("buttons.submit")}
414
+              aria-label={t("buttons.submit")}
415
+              label={t("buttons.submit")}
416
+              className="publish-library__buttons--confirm"
417
+              isLoading={isSubmitting}
418
+            />
419
+          </div>
420
+        </form>
421
+      ) : (
422
+        <p style={{ padding: "1em", textAlign: "center", fontWeight: 500 }}>
423
+          {t("publishDialog.atleastOneLibItem")}
424
+        </p>
425
+      )}
426
+    </Dialog>
427
+  );
428
+};
429
+
430
+export default PublishLibrary;

+ 66
- 0
src/components/SingleLibraryItem.scss View File

@@ -0,0 +1,66 @@
1
+@import "../css/variables.module";
2
+
3
+.excalidraw {
4
+  .single-library-item {
5
+    position: relative;
6
+    &__svg {
7
+      width: 7.5rem;
8
+      height: 7.5rem;
9
+      border: 1px solid var(--button-gray-2);
10
+      margin: 0.3rem;
11
+      svg {
12
+        width: 100%;
13
+        height: 100%;
14
+      }
15
+    }
16
+
17
+    .ToolIcon__icon {
18
+      background-color: $oc-white;
19
+      width: auto;
20
+      height: auto;
21
+      margin: 0 0.5rem;
22
+    }
23
+    .ToolIcon,
24
+    .ToolIcon_type_button:hover {
25
+      background-color: white;
26
+    }
27
+    .required,
28
+    .error {
29
+      color: $oc-red-8;
30
+      font-weight: bold;
31
+      font-size: 1rem;
32
+      margin: 0.2rem;
33
+    }
34
+    .error {
35
+      font-weight: 500;
36
+      margin: 0;
37
+      padding: 0.3em 0;
38
+    }
39
+
40
+    &--remove {
41
+      position: absolute;
42
+      top: 0.2rem;
43
+      right: 1.3rem;
44
+
45
+      .ToolIcon__icon {
46
+        margin: 0;
47
+      }
48
+      .ToolIcon__icon {
49
+        background-color: $oc-red-6;
50
+        &:hover {
51
+          background-color: $oc-red-7;
52
+        }
53
+        &:active {
54
+          background-color: $oc-red-8;
55
+        }
56
+      }
57
+      svg {
58
+        color: $oc-white;
59
+        padding: 0.26rem;
60
+        border-radius: 0.3em;
61
+        width: 1rem;
62
+        height: 1rem;
63
+      }
64
+    }
65
+  }
66
+}

+ 99
- 0
src/components/SingleLibraryItem.tsx View File

@@ -0,0 +1,99 @@
1
+import oc from "open-color";
2
+import { useEffect, useRef } from "react";
3
+import { t } from "../i18n";
4
+import { exportToSvg } from "../packages/utils";
5
+import { AppState, LibraryItem } from "../types";
6
+import { close } from "./icons";
7
+
8
+import "./SingleLibraryItem.scss";
9
+import { ToolButton } from "./ToolButton";
10
+
11
+const SingleLibraryItem = ({
12
+  libItem,
13
+  appState,
14
+  index,
15
+  onChange,
16
+  onRemove,
17
+}: {
18
+  libItem: LibraryItem;
19
+  appState: AppState;
20
+  index: number;
21
+  onChange: (val: string, index: number) => void;
22
+  onRemove: (id: string) => void;
23
+}) => {
24
+  const svgRef = useRef<HTMLDivElement | null>(null);
25
+  const inputRef = useRef<HTMLInputElement | null>(null);
26
+
27
+  useEffect(() => {
28
+    const node = svgRef.current;
29
+    if (!node) {
30
+      return;
31
+    }
32
+    (async () => {
33
+      const svg = await exportToSvg({
34
+        elements: libItem.elements,
35
+        appState: {
36
+          ...appState,
37
+          viewBackgroundColor: oc.white,
38
+          exportBackground: true,
39
+        },
40
+        files: null,
41
+      });
42
+      node.innerHTML = svg.outerHTML;
43
+    })();
44
+  }, [libItem.elements, appState]);
45
+
46
+  return (
47
+    <div className="single-library-item">
48
+      <div ref={svgRef} className="single-library-item__svg" />
49
+      <ToolButton
50
+        aria-label={t("buttons.remove")}
51
+        type="button"
52
+        icon={close}
53
+        className="single-library-item--remove"
54
+        onClick={onRemove.bind(null, libItem.id)}
55
+        title={t("buttons.remove")}
56
+      />
57
+      <div
58
+        style={{
59
+          display: "flex",
60
+          margin: "0.8rem 0.3rem",
61
+          width: "100%",
62
+          fontSize: "14px",
63
+          fontWeight: 500,
64
+          flexDirection: "column",
65
+        }}
66
+      >
67
+        <label
68
+          style={{
69
+            display: "flex",
70
+            justifyContent: "space-between",
71
+            flexDirection: "column",
72
+          }}
73
+        >
74
+          <div style={{ padding: "0.5em 0" }}>
75
+            <span style={{ fontWeight: 500, color: oc.gray[6] }}>
76
+              {t("publishDialog.itemName")}
77
+            </span>
78
+            <span aria-hidden="true" className="required">
79
+              *
80
+            </span>
81
+          </div>
82
+          <input
83
+            type="text"
84
+            ref={inputRef}
85
+            style={{ width: "80%", padding: "0.2rem" }}
86
+            defaultValue={libItem.name}
87
+            placeholder="Item name"
88
+            onChange={(event) => {
89
+              onChange(event.target.value, index);
90
+            }}
91
+          />
92
+        </label>
93
+        <span className="error">{libItem.error}</span>
94
+      </div>
95
+    </div>
96
+  );
97
+};
98
+
99
+export default SingleLibraryItem;

+ 0
- 18
src/components/TextInput.scss View File

@@ -2,24 +2,6 @@
2 2
 
3 3
 .excalidraw {
4 4
   .TextInput {
5
-    color: var(--text-primary-color);
6 5
     display: inline-block;
7
-    border: 1.5px solid var(--button-gray-1);
8
-    line-height: 1;
9
-    padding: 0.75rem;
10
-    white-space: nowrap;
11
-    border-radius: var(--space-factor);
12
-    background-color: var(--input-bg-color);
13
-
14
-    &:not(:focus) {
15
-      &:hover {
16
-        background-color: var(--input-hover-bg-color);
17
-      }
18
-    }
19
-
20
-    &:focus {
21
-      outline: none;
22
-      box-shadow: 0 0 0 2px var(--focus-highlight-color);
23
-    }
24 6
   }
25 7
 }

+ 17
- 3
src/components/ToolButton.tsx View File

@@ -25,6 +25,7 @@ type ToolButtonBaseProps = {
25 25
   visible?: boolean;
26 26
   selected?: boolean;
27 27
   className?: string;
28
+  isLoading?: boolean;
28 29
 };
29 30
 
30 31
 type ToolButtonProps =
@@ -33,6 +34,11 @@ type ToolButtonProps =
33 34
       children?: React.ReactNode;
34 35
       onClick?(event: React.MouseEvent): void;
35 36
     })
37
+  | (ToolButtonBaseProps & {
38
+      type: "submit";
39
+      children?: React.ReactNode;
40
+      onClick?(event: React.MouseEvent): void;
41
+    })
36 42
   | (ToolButtonBaseProps & {
37 43
       type: "icon";
38 44
       children?: React.ReactNode;
@@ -82,7 +88,14 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
82 88
 
83 89
   const lastPointerTypeRef = useRef<PointerType | null>(null);
84 90
 
85
-  if (props.type === "button" || props.type === "icon") {
91
+  if (
92
+    props.type === "button" ||
93
+    props.type === "icon" ||
94
+    props.type === "submit"
95
+  ) {
96
+    const type = (props.type === "icon" ? "button" : props.type) as
97
+      | "button"
98
+      | "submit";
86 99
     return (
87 100
       <button
88 101
         className={clsx(
@@ -102,10 +115,10 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
102 115
         hidden={props.hidden}
103 116
         title={props.title}
104 117
         aria-label={props["aria-label"]}
105
-        type="button"
118
+        type={type}
106 119
         onClick={onClick}
107 120
         ref={innerRef}
108
-        disabled={isLoading}
121
+        disabled={isLoading || props.isLoading}
109 122
       >
110 123
         {(props.icon || props.label) && (
111 124
           <div className="ToolIcon__icon" aria-hidden="true">
@@ -115,6 +128,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
115 128
                 {props.keyBindingLabel}
116 129
               </span>
117 130
             )}
131
+            {props.isLoading && <Spinner />}
118 132
           </div>
119 133
         )}
120 134
         {props.showAriaLabel && (

+ 9
- 0
src/components/icons.tsx View File

@@ -85,6 +85,7 @@ export const clipboard = createIcon(
85 85
 
86 86
 export const trash = createIcon(
87 87
   "M32 464a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128H32zm272-256a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zM432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z",
88
+
88 89
   { width: 448, height: 512 },
89 90
 );
90 91
 
@@ -882,3 +883,11 @@ export const TextAlignRightIcon = React.memo(({ theme }: { theme: Theme }) =>
882 883
     { width: 448, height: 512 },
883 884
   ),
884 885
 );
886
+
887
+export const publishIcon = createIcon(
888
+  <path
889
+    d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z"
890
+    fill="currentColor"
891
+  />,
892
+  { width: 640, height: 512 },
893
+);

+ 21
- 0
src/css/styles.scss View File

@@ -517,6 +517,27 @@
517 517
     }
518 518
   }
519 519
 
520
+  input[type="text"],
521
+  textarea:not(.excalidraw-wysiwyg) {
522
+    color: var(--text-primary-color);
523
+    border: 1.5px solid var(--input-border-color);
524
+    padding: 0.75rem;
525
+    white-space: nowrap;
526
+    border-radius: var(--space-factor);
527
+    background-color: var(--input-bg-color);
528
+
529
+    &:not(:focus) {
530
+      &:hover {
531
+        background-color: var(--input-hover-bg-color);
532
+      }
533
+    }
534
+
535
+    &:focus {
536
+      outline: none;
537
+      box-shadow: 0 0 0 2px var(--focus-highlight-color);
538
+    }
539
+  }
540
+
520 541
   @media print {
521 542
     .App-bottom-bar,
522 543
     .FixedSideContainer,

+ 2
- 1
src/css/theme.scss View File

@@ -16,7 +16,7 @@
16 16
   --icon-green-fill-color: #{$oc-green-9};
17 17
   --default-bg-color: #{$oc-white};
18 18
   --input-bg-color: #{$oc-white};
19
-  --input-border-color: #{$oc-gray-3};
19
+  --input-border-color: #{$oc-gray-4};
20 20
   --input-hover-bg-color: #{$oc-gray-1};
21 21
   --input-label-color: #{$oc-gray-7};
22 22
   --island-bg-color: rgba(255, 255, 255, 0.96);
@@ -64,6 +64,7 @@
64 64
     --input-label-color: #{$oc-gray-2};
65 65
     --island-bg-color: rgba(30, 30, 30, 0.98);
66 66
     --keybinding-color: #{$oc-gray-6};
67
+    --link-color: #{$oc-blue-4};
67 68
     --overlay-bg-color: #{transparentize($oc-gray-8, 0.88)};
68 69
     --popup-bg-color: #2c2c2c;
69 70
     --popup-secondary-bg-color: #222;

+ 5
- 6
src/data/json.ts View File

@@ -3,7 +3,7 @@ import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
3 3
 import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
4 4
 import { clearElementsForDatabase, clearElementsForExport } from "../element";
5 5
 import { ExcalidrawElement } from "../element/types";
6
-import { AppState, BinaryFiles } from "../types";
6
+import { AppState, BinaryFiles, LibraryItems } from "../types";
7 7
 import { isImageFileHandle, loadFromBlob } from "./blob";
8 8
 
9 9
 import {
@@ -114,17 +114,16 @@ export const isValidLibrary = (json: any) => {
114 114
     typeof json === "object" &&
115 115
     json &&
116 116
     json.type === EXPORT_DATA_TYPES.excalidrawLibrary &&
117
-    json.version === 1
117
+    (json.version === 1 || json.version === 2)
118 118
   );
119 119
 };
120 120
 
121
-export const saveLibraryAsJSON = async (library: Library) => {
122
-  const libraryItems = await library.loadLibrary();
121
+export const saveLibraryAsJSON = async (libraryItems: LibraryItems) => {
123 122
   const data: ExportedLibraryData = {
124 123
     type: EXPORT_DATA_TYPES.excalidrawLibrary,
125
-    version: 1,
124
+    version: 2,
126 125
     source: EXPORT_SOURCE,
127
-    library: libraryItems,
126
+    libraryItems,
128 127
   };
129 128
   const serialized = JSON.stringify(data, null, 2);
130 129
   await fileSave(

+ 22
- 15
src/data/library.ts View File

@@ -1,6 +1,6 @@
1 1
 import { loadLibraryFromBlob } from "./blob";
2 2
 import { LibraryItems, LibraryItem } from "../types";
3
-import { restoreElements } from "./restore";
3
+import { restoreElements, restoreLibraryItems } from "./restore";
4 4
 import { getNonDeletedElements } from "../element";
5 5
 import type App from "../components/App";
6 6
 
@@ -18,14 +18,16 @@ class Library {
18 18
   };
19 19
 
20 20
   restoreLibraryItem = (libraryItem: LibraryItem): LibraryItem | null => {
21
-    const elements = getNonDeletedElements(restoreElements(libraryItem, null));
22
-    return elements.length ? elements : null;
21
+    const elements = getNonDeletedElements(
22
+      restoreElements(libraryItem.elements, null),
23
+    );
24
+    return elements.length ? { ...libraryItem, elements } : null;
23 25
   };
24 26
 
25 27
   /** imports library (currently merges, removing duplicates) */
26
-  async importLibrary(blob: Blob) {
28
+  async importLibrary(blob: Blob, defaultStatus = "unpublished") {
27 29
     const libraryFile = await loadLibraryFromBlob(blob);
28
-    if (!libraryFile || !libraryFile.library) {
30
+    if (!libraryFile || !(libraryFile.libraryItems || libraryFile.library)) {
29 31
       return;
30 32
     }
31 33
 
@@ -37,17 +39,17 @@ class Library {
37 39
       targetLibraryItem: LibraryItem,
38 40
     ) => {
39 41
       return !existingLibraryItems.find((libraryItem) => {
40
-        if (libraryItem.length !== targetLibraryItem.length) {
42
+        if (libraryItem.elements.length !== targetLibraryItem.elements.length) {
41 43
           return false;
42 44
         }
43 45
 
44 46
         // detect z-index difference by checking the excalidraw elements
45 47
         // are in order
46
-        return libraryItem.every((libItemExcalidrawItem, idx) => {
48
+        return libraryItem.elements.every((libItemExcalidrawItem, idx) => {
47 49
           return (
48
-            libItemExcalidrawItem.id === targetLibraryItem[idx].id &&
50
+            libItemExcalidrawItem.id === targetLibraryItem.elements[idx].id &&
49 51
             libItemExcalidrawItem.versionNonce ===
50
-              targetLibraryItem[idx].versionNonce
52
+              targetLibraryItem.elements[idx].versionNonce
51 53
           );
52 54
         });
53 55
       });
@@ -55,15 +57,20 @@ class Library {
55 57
 
56 58
     const existingLibraryItems = await this.loadLibrary();
57 59
 
58
-    const filtered = libraryFile.library!.reduce((acc, libraryItem) => {
59
-      const restoredItem = this.restoreLibraryItem(libraryItem);
60
+    const library = libraryFile.libraryItems || libraryFile.library || [];
61
+    const restoredLibItems = restoreLibraryItems(
62
+      library,
63
+      defaultStatus as "published" | "unpublished",
64
+    );
65
+    const filteredItems = [];
66
+    for (const item of restoredLibItems) {
67
+      const restoredItem = this.restoreLibraryItem(item as LibraryItem);
60 68
       if (restoredItem && isUniqueitem(existingLibraryItems, restoredItem)) {
61
-        acc.push(restoredItem);
69
+        filteredItems.push(restoredItem);
62 70
       }
63
-      return acc;
64
-    }, [] as Mutable<LibraryItems>);
71
+    }
65 72
 
66
-    await this.saveLibrary([...existingLibraryItems, ...filtered]);
73
+    await this.saveLibrary([...filteredItems, ...existingLibraryItems]);
67 74
   }
68 75
 
69 76
   loadLibrary = (): Promise<LibraryItems> => {

+ 33
- 1
src/data/restore.ts View File

@@ -3,7 +3,12 @@ import {
3 3
   ExcalidrawSelectionElement,
4 4
   FontFamilyValues,
5 5
 } from "../element/types";
6
-import { AppState, BinaryFiles, NormalizedZoomValue } from "../types";
6
+import {
7
+  AppState,
8
+  BinaryFiles,
9
+  LibraryItem,
10
+  NormalizedZoomValue,
11
+} from "../types";
7 12
 import { ImportedDataState } from "./types";
8 13
 import {
9 14
   getElementMap,
@@ -273,3 +278,30 @@ export const restore = (
273 278
     files: data?.files || {},
274 279
   };
275 280
 };
281
+
282
+export const restoreLibraryItems = (
283
+  libraryItems: NonOptional<ImportedDataState["libraryItems"]>,
284
+  defaultStatus: LibraryItem["status"],
285
+) => {
286
+  const restoredItems: LibraryItem[] = [];
287
+  for (const item of libraryItems) {
288
+    // migrate older libraries
289
+    if (Array.isArray(item)) {
290
+      restoredItems.push({
291
+        status: defaultStatus,
292
+        elements: item,
293
+        id: randomId(),
294
+        created: Date.now(),
295
+      });
296
+    } else {
297
+      const _item = item as MarkOptional<LibraryItem, "id" | "status">;
298
+      restoredItems.push({
299
+        ..._item,
300
+        id: _item.id || randomId(),
301
+        status: _item.status || defaultStatus,
302
+        created: _item.created || Date.now(),
303
+      });
304
+    }
305
+  }
306
+  return restoredItems;
307
+};

+ 8
- 5
src/data/types.ts View File

@@ -1,5 +1,5 @@
1 1
 import { ExcalidrawElement } from "../element/types";
2
-import { AppState, BinaryFiles, LibraryItems } from "../types";
2
+import { AppState, BinaryFiles, LibraryItems, LibraryItems_v1 } from "../types";
3 3
 import type { cleanAppStateForExport } from "../appState";
4 4
 
5 5
 export interface ExportedDataState {
@@ -18,15 +18,18 @@ export interface ImportedDataState {
18 18
   elements?: readonly ExcalidrawElement[] | null;
19 19
   appState?: Readonly<Partial<AppState>> | null;
20 20
   scrollToContent?: boolean;
21
-  libraryItems?: LibraryItems;
21
+  libraryItems?: LibraryItems | LibraryItems_v1;
22 22
   files?: BinaryFiles;
23 23
 }
24 24
 
25 25
 export interface ExportedLibraryData {
26 26
   type: string;
27
-  version: number;
27
+  version: 2;
28 28
   source: string;
29
-  library: LibraryItems;
29
+  libraryItems: LibraryItems;
30 30
 }
31 31
 
32
-export interface ImportedLibraryData extends Partial<ExportedLibraryData> {}
32
+export interface ImportedLibraryData extends Partial<ExportedLibraryData> {
33
+  /** @deprecated v1 */
34
+  library?: LibraryItems;
35
+}

+ 15
- 0
src/element/bounds.ts View File

@@ -3,6 +3,7 @@ import {
3 3
   ExcalidrawLinearElement,
4 4
   Arrowhead,
5 5
   ExcalidrawFreeDrawElement,
6
+  NonDeleted,
6 7
 } from "./types";
7 8
 import { distance2d, rotate } from "../math";
8 9
 import rough from "roughjs/bin/rough";
@@ -513,3 +514,17 @@ export const getClosestElementBounds = (
513 514
 
514 515
   return getElementBounds(closestElement);
515 516
 };
517
+
518
+export interface Box {
519
+  minX: number;
520
+  minY: number;
521
+  maxX: number;
522
+  maxY: number;
523
+}
524
+
525
+export const getCommonBoundingBox = (
526
+  elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
527
+): Box => {
528
+  const [minX, minY, maxX, maxY] = getCommonBounds(elements);
529
+  return { minX, minY, maxX, maxY };
530
+};

+ 1
- 0
src/element/textWysiwyg.tsx View File

@@ -108,6 +108,7 @@ export const textWysiwyg = ({
108 108
   editable.dataset.type = "wysiwyg";
109 109
   // prevent line wrapping on Safari
110 110
   editable.wrap = "off";
111
+  editable.classList.add("excalidraw-wysiwyg");
111 112
 
112 113
   Object.assign(editable.style, {
113 114
     position: "absolute",

+ 1
- 6
src/excalidraw-app/collab/RoomDialog.scss View File

@@ -6,7 +6,7 @@
6 6
     margin: 1.5em 0;
7 7
   }
8 8
 
9
-  .RoomDialog-link {
9
+  input.RoomDialog-link {
10 10
     color: var(--text-primary-color);
11 11
     min-width: 0;
12 12
     flex: 1 1 auto;
@@ -14,8 +14,6 @@
14 14
     display: inline-block;
15 15
     cursor: pointer;
16 16
     border: none;
17
-    height: 2.5rem;
18
-    line-height: 2.5rem;
19 17
     padding: 0 0.5rem;
20 18
     white-space: nowrap;
21 19
     border-radius: var(--space-factor);
@@ -55,10 +53,7 @@
55 53
       margin-top: 0.5em;
56 54
       margin-inline-start: 0;
57 55
     }
58
-    height: 2.5rem;
59 56
     font-size: 1em;
60
-    line-height: 1.5;
61
-    padding: 0 0.5rem;
62 57
   }
63 58
 
64 59
   .RoomDialog-sessionStartButtonContainer {

+ 2
- 0
src/excalidraw-app/collab/RoomDialog.tsx View File

@@ -124,6 +124,7 @@ const RoomDialog = ({
124 124
                 />
125 125
               </Stack.Row>
126 126
               <input
127
+                type="text"
127 128
                 value={activeRoomLink}
128 129
                 readOnly={true}
129 130
                 className="RoomDialog-link"
@@ -136,6 +137,7 @@ const RoomDialog = ({
136 137
                 {t("labels.yourName")}
137 138
               </label>
138 139
               <input
140
+                type="text"
139 141
                 id="username"
140 142
                 value={username || ""}
141 143
                 className="RoomDialog-username TextInput"

+ 2
- 0
src/global.d.ts View File

@@ -50,6 +50,8 @@ type MarkNonNullable<T, K extends keyof T> = {
50 50
   [P in K]-?: P extends K ? NonNullable<T[P]> : T[P];
51 51
 } & { [P in keyof T]: T[P] };
52 52
 
53
+type NonOptional<T> = Exclude<T, undefined>;
54
+
53 55
 // PNG encoding/decoding
54 56
 // -----------------------------------------------------------------------------
55 57
 type TEXtChunk = { name: "tEXt"; data: Uint8Array };

+ 5
- 2
src/i18n.ts View File

@@ -105,7 +105,10 @@ const findPartsForData = (data: any, parts: string[]) => {
105 105
   return data;
106 106
 };
107 107
 
108
-export const t = (path: string, replacement?: { [key: string]: string }) => {
108
+export const t = (
109
+  path: string,
110
+  replacement?: { [key: string]: string | number },
111
+) => {
109 112
   if (currentLang.code.startsWith(TEST_LANG_CODE)) {
110 113
     const name = replacement
111 114
       ? `${path}(${JSON.stringify(replacement).slice(1, -1)})`
@@ -123,7 +126,7 @@ export const t = (path: string, replacement?: { [key: string]: string }) => {
123 126
 
124 127
   if (replacement) {
125 128
     for (const key in replacement) {
126
-      translation = translation.replace(`{{${key}}}`, replacement[key]);
129
+      translation = translation.replace(`{{${key}}}`, String(replacement[key]));
127 130
     }
128 131
   }
129 132
   return translation;

+ 59
- 2
src/locales/en.json View File

@@ -100,7 +100,9 @@
100 100
     "share": "Share",
101 101
     "showStroke": "Show stroke color picker",
102 102
     "showBackground": "Show background color picker",
103
-    "toggleTheme": "Toggle theme"
103
+    "toggleTheme": "Toggle theme",
104
+    "personalLib": "Personal Library",
105
+    "excalidrawLib": "Excalidraw Library"
104 106
   },
105 107
   "buttons": {
106 108
     "clearReset": "Reset the canvas",
@@ -136,6 +138,9 @@
136 138
     "exitZenMode": "Exit zen mode",
137 139
     "cancel": "Cancel",
138 140
     "clear": "Clear",
141
+    "remove": "Remove",
142
+    "publishLibrary": "Publish",
143
+    "submit": "Submit",
139 144
     "confirm": "Confirm"
140 145
   },
141 146
   "alerts": {
@@ -158,6 +163,7 @@
158 163
     "cannotRestoreFromImage": "Scene couldn't be restored from this image file",
159 164
     "invalidSceneUrl": "Couldn't import scene from the supplied URL. It's either malformed, or doesn't contain valid Excalidraw JSON data.",
160 165
     "resetLibrary": "This will clear your library. Are you sure?",
166
+    "removeItemsFromsLibrary": "Delete {{count}} item(s) from library?",
161 167
     "invalidEncryptionKey": "Encryption key must be of 22 characters. Live collaboration is disabled."
162 168
   },
163 169
   "errors": {
@@ -200,7 +206,8 @@
200 206
     "lineEditor_info": "Double-click or press Enter to edit points",
201 207
     "lineEditor_pointSelected": "Press Delete to remove point, CtrlOrCmd+D to duplicate, or drag to move",
202 208
     "lineEditor_nothingSelected": "Select a point to move or remove, or hold Alt and click to add new points",
203
-    "placeImage": "Click to place the image, or click and drag to set its size manually"
209
+    "placeImage": "Click to place the image, or click and drag to set its size manually",
210
+    "publishLibrary": "Publish your own library"
204 211
   },
205 212
   "canvasError": {
206 213
     "cannotShowPreview": "Cannot show preview",
@@ -270,6 +277,55 @@
270 277
   "clearCanvasDialog": {
271 278
     "title": "Clear canvas"
272 279
   },
280
+  "publishDialog": {
281
+    "title": "Publish library",
282
+    "itemName": "Item name",
283
+    "authorName": "Author name",
284
+    "githubUsername": "GitHub username",
285
+    "twitterUsername": "Twitter username",
286
+    "libraryName": "Library name",
287
+    "libraryDesc": "Library description",
288
+    "website": "Website",
289
+    "placeholder": {
290
+      "authorName": "Your name or username",
291
+      "libraryName": "Name of your library",
292
+      "libraryDesc": "Description of your library to help people understand its usage",
293
+      "githubHandle": "Github handle (optional), so you can edit the library once submitted for review",
294
+      "twitterHandle": "Twitter username (optional), so we know who to credit when promoting over Twitter",
295
+      "website": "Link to your personal website or elsewhere (optional)"
296
+    },
297
+    "errors": {
298
+      "required": "Required",
299
+      "letter&Spaces": "Only letters and spaces allowed"
300
+    },
301
+    "noteDescription": {
302
+      "pre": "Submit your library to be included in the ",
303
+      "link": "public library repository",
304
+      "post": "for other people to use in their drawings."
305
+    },
306
+    "noteGuidelines": {
307
+      "pre": "The library needs to be manually approved first. Please read the ",
308
+      "link": "guidelines",
309
+      "post": " before submitting. You will need a GitHub account to communicate and make changes if requested, but it is not strictly required."
310
+    },
311
+    "noteLicense": {
312
+      "pre": "By submitting, you agree the library will be published under the ",
313
+      "link": "MIT License, ",
314
+      "post": "which in short means anyone can use them without restrictions."
315
+    },
316
+    "noteItems": "Each library item must have its own name so it's filterable. The following library items will be included:",
317
+    "atleastOneLibItem": "Please select at least one library item to get started"
318
+  },
319
+
320
+  "publishSuccessDialog": {
321
+    "title": "Library submitted",
322
+    "content": "Thank you {{authorName}}. Your library has been submitted for review. You can track the status",
323
+    "link": "here"
324
+  },
325
+  "confirmDialog": {
326
+    "resetLibrary": "Reset library",
327
+    "removeItemsFromLib": "Remove selected items from library"
328
+  },
273 329
   "encrypted": {
274 330
     "tooltip": "Your drawings are end-to-end encrypted so Excalidraw's servers will never see them.",
275 331
     "link": "Blog post on end-to-end encryption in Excalidraw"
@@ -290,6 +346,7 @@
290 346
     "width": "Width"
291 347
   },
292 348
   "toast": {
349
+    "addedToLibrary": "Added to library",
293 350
     "copyStyles": "Copied styles.",
294 351
     "copyToClipboard": "Copied to clipboard.",
295 352
     "copyToClipboardAsPng": "Copied {{exportSelection}} to clipboard as PNG\n({{exportColorScheme}})",

+ 2
- 2
src/packages/utils.ts View File

@@ -4,13 +4,13 @@ import {
4 4
 } from "../scene/export";
5 5
 import { getDefaultAppState } from "../appState";
6 6
 import { AppState, BinaryFiles } from "../types";
7
-import { ExcalidrawElement } from "../element/types";
7
+import { ExcalidrawElement, NonDeleted } from "../element/types";
8 8
 import { getNonDeletedElements } from "../element";
9 9
 import { restore } from "../data/restore";
10 10
 import { MIME_TYPES } from "../constants";
11 11
 
12 12
 type ExportOpts = {
13
-  elements: readonly ExcalidrawElement[];
13
+  elements: readonly NonDeleted<ExcalidrawElement>[];
14 14
   appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
15 15
   files: BinaryFiles | null;
16 16
   getDimensions?: (

+ 2
- 2
src/tests/__snapshots__/contextmenu.test.tsx.snap View File

@@ -66,7 +66,7 @@ Object {
66 66
   "startBoundElement": null,
67 67
   "suggestedBindings": Array [],
68 68
   "theme": "light",
69
-  "toastMessage": null,
69
+  "toastMessage": "Added to library",
70 70
   "viewBackgroundColor": "#ffffff",
71 71
   "viewModeEnabled": false,
72 72
   "width": 200,
@@ -166,7 +166,7 @@ Object {
166 166
 
167 167
 exports[`contextMenu element selecting 'Add to library' in context menu adds element to library: [end of test] number of elements 1`] = `1`;
168 168
 
169
-exports[`contextMenu element selecting 'Add to library' in context menu adds element to library: [end of test] number of renders 1`] = `9`;
169
+exports[`contextMenu element selecting 'Add to library' in context menu adds element to library: [end of test] number of renders 1`] = `10`;
170 170
 
171 171
 exports[`contextMenu element selecting 'Bring forward' in context menu brings element forward: [end of test] appState 1`] = `
172 172
 Object {

+ 3
- 2
src/tests/contextmenu.test.tsx View File

@@ -20,6 +20,7 @@ import { copiedStyles } from "../actions/actionStyles";
20 20
 import { API } from "./helpers/api";
21 21
 import { setDateTimeForTests } from "../utils";
22 22
 import { t } from "../i18n";
23
+import { LibraryItem } from "../types";
23 24
 
24 25
 const checkpoint = (name: string) => {
25 26
   expect(renderScene.mock.calls.length).toMatchSnapshot(
@@ -392,8 +393,8 @@ describe("contextMenu element", () => {
392 393
     await waitFor(() => {
393 394
       const library = localStorage.getItem("excalidraw-library");
394 395
       expect(library).not.toBeNull();
395
-      const addedElement = JSON.parse(library!)[0][0];
396
-      expect(addedElement).toEqual(h.elements[0]);
396
+      const addedElement = JSON.parse(library!)[0] as LibraryItem;
397
+      expect(addedElement.elements[0]).toEqual(h.elements[0]);
397 398
     });
398 399
   });
399 400
 

+ 6
- 1
src/tests/library.test.tsx View File

@@ -20,7 +20,12 @@ describe("library", () => {
20 20
     );
21 21
     await waitFor(async () => {
22 22
       expect(await h.app.library.loadLibrary()).toEqual([
23
-        [expect.objectContaining({ id: "A" })],
23
+        {
24
+          status: "unpublished",
25
+          elements: [expect.objectContaining({ id: "A" })],
26
+          id: "id0",
27
+          created: expect.any(Number),
28
+        },
24 29
       ]);
25 30
     });
26 31
   });

+ 18
- 1
src/types.ts View File

@@ -178,8 +178,25 @@ export declare class GestureEvent extends UIEvent {
178 178
   readonly scale: number;
179 179
 }
180 180
 
181
-export type LibraryItem = readonly NonDeleted<ExcalidrawElement>[];
181
+// libraries
182
+// -----------------------------------------------------------------------------
183
+/** @deprecated legacy: do not use outside of migration paths */
184
+export type LibraryItem_v1 = readonly NonDeleted<ExcalidrawElement>[];
185
+/** @deprecated legacy: do not use outside of migration paths */
186
+export type LibraryItems_v1 = readonly LibraryItem_v1[];
187
+
188
+/** v2 library item */
189
+export type LibraryItem = {
190
+  id: string;
191
+  status: "published" | "unpublished";
192
+  elements: readonly NonDeleted<ExcalidrawElement>[];
193
+  /** timestamp in epoch (ms) */
194
+  created: number;
195
+  name?: string;
196
+  error?: string;
197
+};
182 198
 export type LibraryItems = readonly LibraryItem[];
199
+// -----------------------------------------------------------------------------
183 200
 
184 201
 // NOTE ready/readyPromise props are optional for host apps' sake (our own
185 202
 // implem guarantees existence)

+ 14
- 0
src/utils.ts View File

@@ -150,6 +150,20 @@ export const debounce = <T extends any[]>(
150 150
   return ret;
151 151
 };
152 152
 
153
+// https://github.com/lodash/lodash/blob/es/chunk.js
154
+export const chunk = <T extends any>(array: T[], size: number): T[][] => {
155
+  if (!array.length || size < 1) {
156
+    return [];
157
+  }
158
+  let index = 0;
159
+  let resIndex = 0;
160
+  const result = Array(Math.ceil(array.length / size));
161
+  while (index < array.length) {
162
+    result[resIndex++] = array.slice(index, (index += size));
163
+  }
164
+  return result;
165
+};
166
+
153 167
 export const selectNode = (node: Element) => {
154 168
   const selection = window.getSelection();
155 169
   if (selection) {

Loading…
Cancel
Save