Browse Source

feat: image support (#4011)

Co-authored-by: Emil Atanasov <heitara@gmail.com>
Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
vanilla_orig
David Luzar 3 years ago
parent
commit
163ad1f4c4
No account linked to committer's email address
85 changed files with 3536 additions and 618 deletions
  1. 1
    0
      .eslintignore
  2. 4
    0
      package.json
  3. 3
    1
      src/actions/actionCanvas.tsx
  4. 4
    2
      src/actions/actionClipboard.tsx
  5. 16
    9
      src/actions/actionExport.tsx
  6. 6
    0
      src/actions/actionFinalize.tsx
  7. 4
    4
      src/actions/actionFlip.ts
  8. 8
    5
      src/actions/actionProperties.tsx
  9. 3
    13
      src/actions/manager.tsx
  10. 9
    9
      src/actions/types.ts
  11. 83
    67
      src/appState.ts
  12. 20
    6
      src/clipboard.ts
  13. 26
    8
      src/components/Actions.tsx
  14. 667
    57
      src/components/App.tsx
  15. 4
    0
      src/components/Card.scss
  16. 2
    0
      src/components/HelpDialog.tsx
  17. 12
    2
      src/components/HintViewer.tsx
  18. 21
    20
      src/components/ImageExportDialog.tsx
  19. 11
    4
      src/components/JSONExportDialog.tsx
  20. 39
    7
      src/components/LayerUI.tsx
  21. 2
    2
      src/components/LibraryButton.tsx
  22. 18
    25
      src/components/LibraryUnit.tsx
  23. 7
    0
      src/components/MobileMenu.tsx
  24. 8
    4
      src/components/PasteChartDialog.tsx
  25. 48
    0
      src/components/Spinner.scss
  26. 28
    0
      src/components/Spinner.tsx
  27. 58
    7
      src/components/ToolButton.tsx
  28. 6
    0
      src/components/ToolIcon.scss
  29. 20
    0
      src/constants.ts
  30. 130
    13
      src/data/blob.ts
  31. 267
    4
      src/data/encode.ts
  32. 79
    0
      src/data/encryption.ts
  33. 2
    10
      src/data/filesystem.ts
  34. 1
    1
      src/data/image.ts
  35. 18
    13
      src/data/index.ts
  36. 42
    15
      src/data/json.ts
  37. 3
    1
      src/data/resave.ts
  38. 23
    11
      src/data/restore.ts
  39. 3
    1
      src/data/types.ts
  40. 13
    3
      src/element/collision.ts
  41. 18
    11
      src/element/dragElements.ts
  42. 111
    0
      src/element/image.ts
  43. 5
    0
      src/element/index.ts
  44. 26
    10
      src/element/mutateElement.ts
  45. 17
    0
      src/element/newElement.ts
  46. 40
    28
      src/element/resizeElements.ts
  47. 14
    0
      src/element/typeChecks.ts
  48. 22
    3
      src/element/types.ts
  49. 11
    0
      src/excalidraw-app/app_constants.ts
  50. 128
    9
      src/excalidraw-app/collab/CollabWrapper.tsx
  51. 38
    1
      src/excalidraw-app/collab/Portal.tsx
  52. 45
    33
      src/excalidraw-app/components/ExportToExcalidrawPlus.tsx
  53. 249
    0
      src/excalidraw-app/data/FileManager.ts
  54. 118
    12
      src/excalidraw-app/data/firebase.ts
  55. 51
    47
      src/excalidraw-app/data/index.ts
  56. 259
    39
      src/excalidraw-app/index.tsx
  57. 39
    0
      src/global.d.ts
  58. 1
    0
      src/index-node.ts
  59. 5
    5
      src/keys.ts
  60. 12
    2
      src/locales/en.json
  61. 25
    0
      src/packages/excalidraw/CHANGELOG.md
  62. 18
    2
      src/packages/excalidraw/README_NEXT.md
  63. 8
    0
      src/packages/excalidraw/index.tsx
  64. 21
    11
      src/packages/utils.ts
  65. 155
    19
      src/renderer/renderElement.ts
  66. 9
    3
      src/renderer/renderScene.ts
  67. 2
    0
      src/scene/comparisons.ts
  68. 27
    9
      src/scene/export.ts
  69. 3
    2
      src/scene/types.ts
  70. 17
    3
      src/shapes.tsx
  71. 16
    0
      src/tests/__snapshots__/contextmenu.test.tsx.snap
  72. 52
    0
      src/tests/__snapshots__/regressionTests.test.tsx.snap
  73. 2
    2
      src/tests/appState.test.tsx
  74. 10
    0
      src/tests/collab.test.tsx
  75. 2
    2
      src/tests/export.test.tsx
  76. 1
    0
      src/tests/fixtures/diagramFixture.ts
  77. 2
    2
      src/tests/history.test.tsx
  78. 1
    0
      src/tests/packages/__snapshots__/utils.test.ts.snap
  79. 10
    8
      src/tests/packages/utils.test.ts
  80. 1
    1
      src/tests/scene/__snapshots__/export.test.ts.snap
  81. 48
    23
      src/tests/scene/export.test.ts
  82. 2
    0
      src/tests/test-utils.ts
  83. 46
    1
      src/types.ts
  84. 9
    3
      src/utils.ts
  85. 121
    3
      yarn.lock

+ 1
- 0
.eslintignore View File

@@ -5,3 +5,4 @@ package-lock.json
5 5
 firebase/
6 6
 dist/
7 7
 public/workbox
8
+src/packages/excalidraw/types

+ 4
- 0
package.json View File

@@ -26,12 +26,16 @@
26 26
     "@testing-library/react": "11.2.6",
27 27
     "@tldraw/vec": "0.0.106",
28 28
     "@types/jest": "26.0.22",
29
+    "@types/pica": "5.1.3",
29 30
     "@types/react": "17.0.3",
30 31
     "@types/react-dom": "17.0.3",
31 32
     "@types/socket.io-client": "1.4.36",
32 33
     "clsx": "1.1.1",
34
+    "fake-indexeddb": "3.1.3",
33 35
     "firebase": "8.3.3",
34 36
     "i18next-browser-languagedetector": "6.1.0",
37
+    "idb-keyval": "5.1.3",
38
+    "image-blob-reduce": "3.0.1",
35 39
     "lodash.throttle": "4.1.1",
36 40
     "nanoid": "3.1.22",
37 41
     "open-color": "1.8.0",

+ 3
- 1
src/actions/actionCanvas.tsx View File

@@ -47,13 +47,15 @@ export const actionChangeViewBackgroundColor = register({
47 47
 
48 48
 export const actionClearCanvas = register({
49 49
   name: "clearCanvas",
50
-  perform: (elements, appState) => {
50
+  perform: (elements, appState, _, app) => {
51
+    app.imageCache.clear();
51 52
     return {
52 53
       elements: elements.map((element) =>
53 54
         newElementWith(element, { isDeleted: true }),
54 55
       ),
55 56
       appState: {
56 57
         ...getDefaultAppState(),
58
+        files: {},
57 59
         theme: appState.theme,
58 60
         elementLocked: appState.elementLocked,
59 61
         exportBackground: appState.exportBackground,

+ 4
- 2
src/actions/actionClipboard.tsx View File

@@ -9,8 +9,8 @@ import { t } from "../i18n";
9 9
 
10 10
 export const actionCopy = register({
11 11
   name: "copy",
12
-  perform: (elements, appState) => {
13
-    copyToClipboard(getNonDeletedElements(elements), appState);
12
+  perform: (elements, appState, _, app) => {
13
+    copyToClipboard(getNonDeletedElements(elements), appState, app.files);
14 14
 
15 15
     return {
16 16
       commitToHistory: false,
@@ -50,6 +50,7 @@ export const actionCopyAsSvg = register({
50 50
           ? selectedElements
51 51
           : getNonDeletedElements(elements),
52 52
         appState,
53
+        app.files,
53 54
         appState,
54 55
       );
55 56
       return {
@@ -88,6 +89,7 @@ export const actionCopyAsPng = register({
88 89
           ? selectedElements
89 90
           : getNonDeletedElements(elements),
90 91
         appState,
92
+        app.files,
91 93
         appState,
92 94
       );
93 95
       return {

+ 16
- 9
src/actions/actionExport.tsx View File

@@ -128,13 +128,13 @@ export const actionChangeExportEmbedScene = register({
128 128
 
129 129
 export const actionSaveToActiveFile = register({
130 130
   name: "saveToActiveFile",
131
-  perform: async (elements, appState, value) => {
131
+  perform: async (elements, appState, value, app) => {
132 132
     const fileHandleExists = !!appState.fileHandle;
133 133
 
134 134
     try {
135 135
       const { fileHandle } = isImageFileHandle(appState.fileHandle)
136
-        ? await resaveAsImageWithScene(elements, appState)
137
-        : await saveAsJSON(elements, appState);
136
+        ? await resaveAsImageWithScene(elements, appState, app.files)
137
+        : await saveAsJSON(elements, appState, app.files);
138 138
 
139 139
       return {
140 140
         commitToHistory: false,
@@ -170,12 +170,16 @@ export const actionSaveToActiveFile = register({
170 170
 
171 171
 export const actionSaveFileToDisk = register({
172 172
   name: "saveFileToDisk",
173
-  perform: async (elements, appState, value) => {
173
+  perform: async (elements, appState, value, app) => {
174 174
     try {
175
-      const { fileHandle } = await saveAsJSON(elements, {
176
-        ...appState,
177
-        fileHandle: null,
178
-      });
175
+      const { fileHandle } = await saveAsJSON(
176
+        elements,
177
+        {
178
+          ...appState,
179
+          fileHandle: null,
180
+        },
181
+        app.files,
182
+      );
179 183
       return { commitToHistory: false, appState: { ...appState, fileHandle } };
180 184
     } catch (error) {
181 185
       if (error?.name !== "AbortError") {
@@ -202,15 +206,17 @@ export const actionSaveFileToDisk = register({
202 206
 
203 207
 export const actionLoadScene = register({
204 208
   name: "loadScene",
205
-  perform: async (elements, appState) => {
209
+  perform: async (elements, appState, _, app) => {
206 210
     try {
207 211
       const {
208 212
         elements: loadedElements,
209 213
         appState: loadedAppState,
214
+        files,
210 215
       } = await loadFromJSON(appState, elements);
211 216
       return {
212 217
         elements: loadedElements,
213 218
         appState: loadedAppState,
219
+        files,
214 220
         commitToHistory: true,
215 221
       };
216 222
     } catch (error) {
@@ -220,6 +226,7 @@ export const actionLoadScene = register({
220 226
       return {
221 227
         elements,
222 228
         appState: { ...appState, errorMessage: error.message },
229
+        files: app.files,
223 230
         commitToHistory: false,
224 231
       };
225 232
     }

+ 6
- 0
src/actions/actionFinalize.tsx View File

@@ -49,6 +49,11 @@ export const actionFinalize = register({
49 49
     }
50 50
 
51 51
     let newElements = elements;
52
+
53
+    if (appState.pendingImageElement) {
54
+      mutateElement(appState.pendingImageElement, { isDeleted: true }, false);
55
+    }
56
+
52 57
     if (window.document.activeElement instanceof HTMLElement) {
53 58
       focusContainer();
54 59
     }
@@ -152,6 +157,7 @@ export const actionFinalize = register({
152 157
                 [multiPointElement.id]: true,
153 158
               }
154 159
             : appState.selectedElementIds,
160
+        pendingImageElement: null,
155 161
       },
156 162
       commitToHistory: appState.elementType === "freedraw",
157 163
     };

+ 4
- 4
src/actions/actionFlip.ts View File

@@ -93,13 +93,13 @@ const flipElements = (
93 93
   appState: AppState,
94 94
   flipDirection: "horizontal" | "vertical",
95 95
 ): ExcalidrawElement[] => {
96
-  for (let i = 0; i < elements.length; i++) {
97
-    flipElement(elements[i], appState);
96
+  elements.forEach((element) => {
97
+    flipElement(element, appState);
98 98
     // If vertical flip, rotate an extra 180
99 99
     if (flipDirection === "vertical") {
100
-      rotateElement(elements[i], Math.PI);
100
+      rotateElement(element, Math.PI);
101 101
     }
102
-  }
102
+  });
103 103
   return elements;
104 104
 };
105 105
 

+ 8
- 5
src/actions/actionProperties.tsx View File

@@ -59,6 +59,7 @@ import {
59 59
   getTargetElements,
60 60
   isSomeElementSelected,
61 61
 } from "../scene";
62
+import { hasStrokeColor } from "../scene/comparisons";
62 63
 import { register } from "./register";
63 64
 
64 65
 const changeProperty = (
@@ -103,11 +104,13 @@ export const actionChangeStrokeColor = register({
103 104
   perform: (elements, appState, value) => {
104 105
     return {
105 106
       ...(value.currentItemStrokeColor && {
106
-        elements: changeProperty(elements, appState, (el) =>
107
-          newElementWith(el, {
108
-            strokeColor: value.currentItemStrokeColor,
109
-          }),
110
-        ),
107
+        elements: changeProperty(elements, appState, (el) => {
108
+          return hasStrokeColor(el.type)
109
+            ? newElementWith(el, {
110
+                strokeColor: value.currentItemStrokeColor,
111
+              })
112
+            : el;
113
+        }),
111 114
       }),
112 115
       appState: {
113 116
         ...appState,

+ 3
- 13
src/actions/manager.tsx View File

@@ -8,18 +8,8 @@ import {
8 8
   PanelComponentProps,
9 9
 } from "./types";
10 10
 import { ExcalidrawElement } from "../element/types";
11
-import { AppProps, AppState } from "../types";
11
+import { AppClassProperties, AppState } from "../types";
12 12
 import { MODES } from "../constants";
13
-import Library from "../data/library";
14
-
15
-// This is the <App> component, but for now we don't care about anything but its
16
-// `canvas` state.
17
-type App = {
18
-  canvas: HTMLCanvasElement | null;
19
-  focusContainer: () => void;
20
-  props: AppProps;
21
-  library: Library;
22
-};
23 13
 
24 14
 export class ActionManager implements ActionsManagerInterface {
25 15
   actions = {} as ActionsManagerInterface["actions"];
@@ -28,13 +18,13 @@ export class ActionManager implements ActionsManagerInterface {
28 18
 
29 19
   getAppState: () => Readonly<AppState>;
30 20
   getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
31
-  app: App;
21
+  app: AppClassProperties;
32 22
 
33 23
   constructor(
34 24
     updater: UpdaterFn,
35 25
     getAppState: () => AppState,
36 26
     getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
37
-    app: App,
27
+    app: AppClassProperties,
38 28
   ) {
39 29
     this.updater = (actionResult) => {
40 30
       if (actionResult && "then" in actionResult) {

+ 9
- 9
src/actions/types.ts View File

@@ -1,7 +1,11 @@
1 1
 import React from "react";
2 2
 import { ExcalidrawElement } from "../element/types";
3
-import { AppState, ExcalidrawProps } from "../types";
4
-import Library from "../data/library";
3
+import {
4
+  AppClassProperties,
5
+  AppState,
6
+  ExcalidrawProps,
7
+  BinaryFiles,
8
+} from "../types";
5 9
 import { ToolButtonSize } from "../components/ToolButton";
6 10
 
7 11
 /** if false, the action should be prevented */
@@ -12,22 +16,18 @@ export type ActionResult =
12 16
         AppState,
13 17
         "offsetTop" | "offsetLeft" | "width" | "height"
14 18
       > | null;
19
+      files?: BinaryFiles | null;
15 20
       commitToHistory: boolean;
16 21
       syncHistory?: boolean;
22
+      replaceFiles?: boolean;
17 23
     }
18 24
   | false;
19 25
 
20
-type AppAPI = {
21
-  canvas: HTMLCanvasElement | null;
22
-  focusContainer(): void;
23
-  library: Library;
24
-};
25
-
26 26
 type ActionFn = (
27 27
   elements: readonly ExcalidrawElement[],
28 28
   appState: Readonly<AppState>,
29 29
   formData: any,
30
-  app: AppAPI,
30
+  app: AppClassProperties,
31 31
 ) => ActionResult | Promise<ActionResult>;
32 32
 
33 33
 export type UpdaterFn = (res: ActionResult) => void;

+ 83
- 67
src/appState.ts View File

@@ -79,6 +79,7 @@ export const getDefaultAppState = (): Omit<
79 79
     zenModeEnabled: false,
80 80
     zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
81 81
     viewModeEnabled: false,
82
+    pendingImageElement: null,
82 83
   };
83 84
 };
84 85
 
@@ -92,78 +93,87 @@ const APP_STATE_STORAGE_CONF = (<
92 93
     browser: boolean;
93 94
     /** whether to keep when exporting to file/database */
94 95
     export: boolean;
96
+    /** server (shareLink/collab/...) */
97
+    server: boolean;
95 98
   },
96 99
   T extends Record<keyof AppState, Values>
97 100
 >(
98 101
   config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
99 102
 ) => config)({
100
-  theme: { browser: true, export: false },
101
-  collaborators: { browser: false, export: false },
102
-  currentChartType: { browser: true, export: false },
103
-  currentItemBackgroundColor: { browser: true, export: false },
104
-  currentItemEndArrowhead: { browser: true, export: false },
105
-  currentItemFillStyle: { browser: true, export: false },
106
-  currentItemFontFamily: { browser: true, export: false },
107
-  currentItemFontSize: { browser: true, export: false },
108
-  currentItemLinearStrokeSharpness: { browser: true, export: false },
109
-  currentItemOpacity: { browser: true, export: false },
110
-  currentItemRoughness: { browser: true, export: false },
111
-  currentItemStartArrowhead: { browser: true, export: false },
112
-  currentItemStrokeColor: { browser: true, export: false },
113
-  currentItemStrokeSharpness: { browser: true, export: false },
114
-  currentItemStrokeStyle: { browser: true, export: false },
115
-  currentItemStrokeWidth: { browser: true, export: false },
116
-  currentItemTextAlign: { browser: true, export: false },
117
-  cursorButton: { browser: true, export: false },
118
-  draggingElement: { browser: false, export: false },
119
-  editingElement: { browser: false, export: false },
120
-  editingGroupId: { browser: true, export: false },
121
-  editingLinearElement: { browser: false, export: false },
122
-  elementLocked: { browser: true, export: false },
123
-  elementType: { browser: true, export: false },
124
-  errorMessage: { browser: false, export: false },
125
-  exportBackground: { browser: true, export: false },
126
-  exportEmbedScene: { browser: true, export: false },
127
-  exportScale: { browser: true, export: false },
128
-  exportWithDarkMode: { browser: true, export: false },
129
-  fileHandle: { browser: false, export: false },
130
-  gridSize: { browser: true, export: true },
131
-  height: { browser: false, export: false },
132
-  isBindingEnabled: { browser: false, export: false },
133
-  isLibraryOpen: { browser: false, export: false },
134
-  isLoading: { browser: false, export: false },
135
-  isResizing: { browser: false, export: false },
136
-  isRotating: { browser: false, export: false },
137
-  lastPointerDownWith: { browser: true, export: false },
138
-  multiElement: { browser: false, export: false },
139
-  name: { browser: true, export: false },
140
-  offsetLeft: { browser: false, export: false },
141
-  offsetTop: { browser: false, export: false },
142
-  openMenu: { browser: true, export: false },
143
-  openPopup: { browser: false, export: false },
144
-  pasteDialog: { browser: false, export: false },
145
-  previousSelectedElementIds: { browser: true, export: false },
146
-  resizingElement: { browser: false, export: false },
147
-  scrolledOutside: { browser: true, export: false },
148
-  scrollX: { browser: true, export: false },
149
-  scrollY: { browser: true, export: false },
150
-  selectedElementIds: { browser: true, export: false },
151
-  selectedGroupIds: { browser: true, export: false },
152
-  selectionElement: { browser: false, export: false },
153
-  shouldCacheIgnoreZoom: { browser: true, export: false },
154
-  showHelpDialog: { browser: false, export: false },
155
-  showStats: { browser: true, export: false },
156
-  startBoundElement: { browser: false, export: false },
157
-  suggestedBindings: { browser: false, export: false },
158
-  toastMessage: { browser: false, export: false },
159
-  viewBackgroundColor: { browser: true, export: true },
160
-  width: { browser: false, export: false },
161
-  zenModeEnabled: { browser: true, export: false },
162
-  zoom: { browser: true, export: false },
163
-  viewModeEnabled: { browser: false, export: false },
103
+  theme: { browser: true, export: false, server: false },
104
+  collaborators: { browser: false, export: false, server: false },
105
+  currentChartType: { browser: true, export: false, server: false },
106
+  currentItemBackgroundColor: { browser: true, export: false, server: false },
107
+  currentItemEndArrowhead: { browser: true, export: false, server: false },
108
+  currentItemFillStyle: { browser: true, export: false, server: false },
109
+  currentItemFontFamily: { browser: true, export: false, server: false },
110
+  currentItemFontSize: { browser: true, export: false, server: false },
111
+  currentItemLinearStrokeSharpness: {
112
+    browser: true,
113
+    export: false,
114
+    server: false,
115
+  },
116
+  currentItemOpacity: { browser: true, export: false, server: false },
117
+  currentItemRoughness: { browser: true, export: false, server: false },
118
+  currentItemStartArrowhead: { browser: true, export: false, server: false },
119
+  currentItemStrokeColor: { browser: true, export: false, server: false },
120
+  currentItemStrokeSharpness: { browser: true, export: false, server: false },
121
+  currentItemStrokeStyle: { browser: true, export: false, server: false },
122
+  currentItemStrokeWidth: { browser: true, export: false, server: false },
123
+  currentItemTextAlign: { browser: true, export: false, server: false },
124
+  cursorButton: { browser: true, export: false, server: false },
125
+  draggingElement: { browser: false, export: false, server: false },
126
+  editingElement: { browser: false, export: false, server: false },
127
+  editingGroupId: { browser: true, export: false, server: false },
128
+  editingLinearElement: { browser: false, export: false, server: false },
129
+  elementLocked: { browser: true, export: false, server: false },
130
+  elementType: { browser: true, export: false, server: false },
131
+  errorMessage: { browser: false, export: false, server: false },
132
+  exportBackground: { browser: true, export: false, server: false },
133
+  exportEmbedScene: { browser: true, export: false, server: false },
134
+  exportScale: { browser: true, export: false, server: false },
135
+  exportWithDarkMode: { browser: true, export: false, server: false },
136
+  fileHandle: { browser: false, export: false, server: false },
137
+  gridSize: { browser: true, export: true, server: true },
138
+  height: { browser: false, export: false, server: false },
139
+  isBindingEnabled: { browser: false, export: false, server: false },
140
+  isLibraryOpen: { browser: false, export: false, server: false },
141
+  isLoading: { browser: false, export: false, server: false },
142
+  isResizing: { browser: false, export: false, server: false },
143
+  isRotating: { browser: false, export: false, server: false },
144
+  lastPointerDownWith: { browser: true, export: false, server: false },
145
+  multiElement: { browser: false, export: false, server: false },
146
+  name: { browser: true, export: false, server: false },
147
+  offsetLeft: { browser: false, export: false, server: false },
148
+  offsetTop: { browser: false, export: false, server: false },
149
+  openMenu: { browser: true, export: false, server: false },
150
+  openPopup: { browser: false, export: false, server: false },
151
+  pasteDialog: { browser: false, export: false, server: false },
152
+  previousSelectedElementIds: { browser: true, export: false, server: false },
153
+  resizingElement: { browser: false, export: false, server: false },
154
+  scrolledOutside: { browser: true, export: false, server: false },
155
+  scrollX: { browser: true, export: false, server: false },
156
+  scrollY: { browser: true, export: false, server: false },
157
+  selectedElementIds: { browser: true, export: false, server: false },
158
+  selectedGroupIds: { browser: true, export: false, server: false },
159
+  selectionElement: { browser: false, export: false, server: false },
160
+  shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
161
+  showHelpDialog: { browser: false, export: false, server: false },
162
+  showStats: { browser: true, export: false, server: false },
163
+  startBoundElement: { browser: false, export: false, server: false },
164
+  suggestedBindings: { browser: false, export: false, server: false },
165
+  toastMessage: { browser: false, export: false, server: false },
166
+  viewBackgroundColor: { browser: true, export: true, server: true },
167
+  width: { browser: false, export: false, server: false },
168
+  zenModeEnabled: { browser: true, export: false, server: false },
169
+  zoom: { browser: true, export: false, server: false },
170
+  viewModeEnabled: { browser: false, export: false, server: false },
171
+  pendingImageElement: { browser: false, export: false, server: false },
164 172
 });
165 173
 
166
-const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
174
+const _clearAppStateForStorage = <
175
+  ExportType extends "export" | "browser" | "server"
176
+>(
167 177
   appState: Partial<AppState>,
168 178
   exportType: ExportType,
169 179
 ) => {
@@ -176,8 +186,10 @@ const _clearAppStateForStorage = <ExportType extends "export" | "browser">(
176 186
   for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
177 187
     const propConfig = APP_STATE_STORAGE_CONF[key];
178 188
     if (propConfig?.[exportType]) {
179
-      // @ts-ignore see https://github.com/microsoft/TypeScript/issues/31445
180
-      stateForExport[key] = appState[key];
189
+      const nextValue = appState[key];
190
+
191
+      // https://github.com/microsoft/TypeScript/issues/31445
192
+      (stateForExport as any)[key] = nextValue;
181 193
     }
182 194
   }
183 195
   return stateForExport;
@@ -190,3 +202,7 @@ export const clearAppStateForLocalStorage = (appState: Partial<AppState>) => {
190 202
 export const cleanAppStateForExport = (appState: Partial<AppState>) => {
191 203
   return _clearAppStateForStorage(appState, "export");
192 204
 };
205
+
206
+export const clearAppStateForDatabase = (appState: Partial<AppState>) => {
207
+  return _clearAppStateForStorage(appState, "server");
208
+};

+ 20
- 6
src/clipboard.ts View File

@@ -3,19 +3,22 @@ import {
3 3
   NonDeletedExcalidrawElement,
4 4
 } from "./element/types";
5 5
 import { getSelectedElements } from "./scene";
6
-import { AppState } from "./types";
6
+import { AppState, BinaryFiles } from "./types";
7 7
 import { SVG_EXPORT_TAG } from "./scene/export";
8 8
 import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
9
-import { EXPORT_DATA_TYPES } from "./constants";
9
+import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
10
+import { isInitializedImageElement } from "./element/typeChecks";
10 11
 
11 12
 type ElementsClipboard = {
12 13
   type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
13 14
   elements: ExcalidrawElement[];
15
+  files: BinaryFiles | undefined;
14 16
 };
15 17
 
16 18
 export interface ClipboardData {
17 19
   spreadsheet?: Spreadsheet;
18 20
   elements?: readonly ExcalidrawElement[];
21
+  files?: BinaryFiles;
19 22
   text?: string;
20 23
   errorMessage?: string;
21 24
 }
@@ -37,7 +40,7 @@ export const probablySupportsClipboardBlob =
37 40
 
38 41
 const clipboardContainsElements = (
39 42
   contents: any,
40
-): contents is { elements: ExcalidrawElement[] } => {
43
+): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => {
41 44
   if (
42 45
     [
43 46
       EXPORT_DATA_TYPES.excalidraw,
@@ -53,10 +56,18 @@ const clipboardContainsElements = (
53 56
 export const copyToClipboard = async (
54 57
   elements: readonly NonDeletedExcalidrawElement[],
55 58
   appState: AppState,
59
+  files: BinaryFiles,
56 60
 ) => {
61
+  const selectedElements = getSelectedElements(elements, appState);
57 62
   const contents: ElementsClipboard = {
58 63
     type: EXPORT_DATA_TYPES.excalidrawClipboard,
59
-    elements: getSelectedElements(elements, appState),
64
+    elements: selectedElements,
65
+    files: selectedElements.reduce((acc, element) => {
66
+      if (isInitializedImageElement(element) && files[element.fileId]) {
67
+        acc[element.fileId] = files[element.fileId];
68
+      }
69
+      return acc;
70
+    }, {} as BinaryFiles),
60 71
   };
61 72
   const json = JSON.stringify(contents);
62 73
   CLIPBOARD = json;
@@ -138,7 +149,10 @@ export const parseClipboard = async (
138 149
   try {
139 150
     const systemClipboardData = JSON.parse(systemClipboard);
140 151
     if (clipboardContainsElements(systemClipboardData)) {
141
-      return { elements: systemClipboardData.elements };
152
+      return {
153
+        elements: systemClipboardData.elements,
154
+        files: systemClipboardData.files,
155
+      };
142 156
     }
143 157
     return appClipboardData;
144 158
   } catch {
@@ -153,7 +167,7 @@ export const parseClipboard = async (
153 167
 
154 168
 export const copyBlobToClipboardAsPng = async (blob: Blob) => {
155 169
   await navigator.clipboard.write([
156
-    new window.ClipboardItem({ "image/png": blob }),
170
+    new window.ClipboardItem({ [MIME_TYPES.png]: blob }),
157 171
   ]);
158 172
 };
159 173
 

+ 26
- 8
src/components/Actions.tsx View File

@@ -1,7 +1,7 @@
1 1
 import React from "react";
2 2
 import { ActionManager } from "../actions/manager";
3 3
 import { getNonDeletedElements } from "../element";
4
-import { ExcalidrawElement } from "../element/types";
4
+import { ExcalidrawElement, PointerType } from "../element/types";
5 5
 import { t } from "../i18n";
6 6
 import { useIsMobile } from "../components/App";
7 7
 import {
@@ -18,6 +18,7 @@ import { AppState, Zoom } from "../types";
18 18
 import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
19 19
 import Stack from "./Stack";
20 20
 import { ToolButton } from "./ToolButton";
21
+import { hasStrokeColor } from "../scene/comparisons";
21 22
 
22 23
 export const SelectedShapeActions = ({
23 24
   appState,
@@ -48,9 +49,22 @@ export const SelectedShapeActions = ({
48 49
     hasBackground(elementType) ||
49 50
     targetElements.some((element) => hasBackground(element.type));
50 51
 
52
+  let commonSelectedType: string | null = targetElements[0]?.type || null;
53
+
54
+  for (const element of targetElements) {
55
+    if (element.type !== commonSelectedType) {
56
+      commonSelectedType = null;
57
+      break;
58
+    }
59
+  }
60
+
51 61
   return (
52 62
     <div className="panelColumn">
53
-      {renderAction("changeStrokeColor")}
63
+      {((hasStrokeColor(elementType) &&
64
+        elementType !== "image" &&
65
+        commonSelectedType !== "image") ||
66
+        targetElements.some((element) => hasStrokeColor(element.type))) &&
67
+        renderAction("changeStrokeColor")}
54 68
       {showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
55 69
       {showFillIcons && renderAction("changeFillStyle")}
56 70
 
@@ -155,18 +169,20 @@ export const ShapesSwitcher = ({
155 169
   canvas,
156 170
   elementType,
157 171
   setAppState,
172
+  onImageAction,
158 173
 }: {
159 174
   canvas: HTMLCanvasElement | null;
160 175
   elementType: ExcalidrawElement["type"];
161 176
   setAppState: React.Component<any, AppState>["setState"];
177
+  onImageAction: (data: { pointerType: PointerType | null }) => void;
162 178
 }) => (
163 179
   <>
164 180
     {SHAPES.map(({ value, icon, key }, index) => {
165 181
       const label = t(`toolBar.${value}`);
166
-      const letter = typeof key === "string" ? key : key[0];
167
-      const shortcut = `${capitalizeString(letter)} ${t("helpDialog.or")} ${
168
-        index + 1
169
-      }`;
182
+      const letter = key && (typeof key === "string" ? key : key[0]);
183
+      const shortcut = letter
184
+        ? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}`
185
+        : `${index + 1}`;
170 186
       return (
171 187
         <ToolButton
172 188
           className="Shape"
@@ -180,14 +196,16 @@ export const ShapesSwitcher = ({
180 196
           aria-label={capitalizeString(label)}
181 197
           aria-keyshortcuts={shortcut}
182 198
           data-testid={value}
183
-          onChange={() => {
199
+          onChange={({ pointerType }) => {
184 200
             setAppState({
185 201
               elementType: value,
186 202
               multiElement: null,
187 203
               selectedElementIds: {},
188 204
             });
189 205
             setCursorForShape(canvas, value);
190
-            setAppState({});
206
+            if (value === "image") {
207
+              onImageAction({ pointerType });
208
+            }
191 209
           }}
192 210
         />
193 211
       );

+ 667
- 57
src/components/App.tsx
File diff suppressed because it is too large
View File


+ 4
- 0
src/components/Card.scss View File

@@ -48,6 +48,10 @@
48 48
       .ToolIcon__label {
49 49
         color: $oc-white;
50 50
       }
51
+
52
+      .Spinner {
53
+        --spinner-color: #fff;
54
+      }
51 55
     }
52 56
   }
53 57
 }

+ 2
- 0
src/components/HelpDialog.tsx View File

@@ -157,6 +157,8 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
157 157
                   shortcuts={["Shift+P", "7"]}
158 158
                 />
159 159
                 <Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
160
+                <Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
161
+                <Shortcut label={t("toolBar.library")} shortcuts={["0"]} />
160 162
                 <Shortcut
161 163
                   label={t("helpDialog.editSelectedShape")}
162 164
                   shortcuts={[

+ 12
- 2
src/components/HintViewer.tsx View File

@@ -4,7 +4,11 @@ import { getSelectedElements } from "../scene";
4 4
 
5 5
 import "./HintViewer.scss";
6 6
 import { AppState } from "../types";
7
-import { isLinearElement, isTextElement } from "../element/typeChecks";
7
+import {
8
+  isImageElement,
9
+  isLinearElement,
10
+  isTextElement,
11
+} from "../element/typeChecks";
8 12
 import { getShortcutKey } from "../utils";
9 13
 
10 14
 interface Hint {
@@ -30,6 +34,10 @@ const getHints = ({ appState, elements }: Hint) => {
30 34
     return t("hints.text");
31 35
   }
32 36
 
37
+  if (appState.elementType === "image" && appState.pendingImageElement) {
38
+    return t("hints.placeImage");
39
+  }
40
+
33 41
   const selectedElements = getSelectedElements(elements, appState);
34 42
   if (
35 43
     isResizing &&
@@ -40,7 +48,9 @@ const getHints = ({ appState, elements }: Hint) => {
40 48
     if (isLinearElement(targetElement) && targetElement.points.length === 2) {
41 49
       return t("hints.lockAngle");
42 50
     }
43
-    return t("hints.resize");
51
+    return isImageElement(targetElement)
52
+      ? t("hints.resizeImage")
53
+      : t("hints.resize");
44 54
   }
45 55
 
46 56
   if (isRotating && lastPointerDownWith === "mouse") {

+ 21
- 20
src/components/ImageExportDialog.tsx View File

@@ -9,7 +9,7 @@ import { t } from "../i18n";
9 9
 import { useIsMobile } from "./App";
10 10
 import { getSelectedElements, isSomeElementSelected } from "../scene";
11 11
 import { exportToCanvas } from "../scene/export";
12
-import { AppState } from "../types";
12
+import { AppState, BinaryFiles } from "../types";
13 13
 import { Dialog } from "./Dialog";
14 14
 import { clipboard, exportImage } from "./icons";
15 15
 import Stack from "./Stack";
@@ -79,6 +79,7 @@ const ExportButton: React.FC<{
79 79
 const ImageExportModal = ({
80 80
   elements,
81 81
   appState,
82
+  files,
82 83
   exportPadding = DEFAULT_EXPORT_PADDING,
83 84
   actionManager,
84 85
   onExportToPng,
@@ -87,6 +88,7 @@ const ImageExportModal = ({
87 88
 }: {
88 89
   appState: AppState;
89 90
   elements: readonly NonDeletedExcalidrawElement[];
91
+  files: BinaryFiles;
90 92
   exportPadding?: number;
91 93
   actionManager: ActionsManagerInterface;
92 94
   onExportToPng: ExportCB;
@@ -112,29 +114,25 @@ const ImageExportModal = ({
112 114
     if (!previewNode) {
113 115
       return;
114 116
     }
115
-    try {
116
-      const canvas = exportToCanvas(exportedElements, appState, {
117
-        exportBackground,
118
-        viewBackgroundColor,
119
-        exportPadding,
120
-      });
121
-
122
-      // if converting to blob fails, there's some problem that will
123
-      // likely prevent preview and export (e.g. canvas too big)
124
-      canvasToBlob(canvas)
125
-        .then(() => {
117
+    exportToCanvas(exportedElements, appState, files, {
118
+      exportBackground,
119
+      viewBackgroundColor,
120
+      exportPadding,
121
+    })
122
+      .then((canvas) => {
123
+        // if converting to blob fails, there's some problem that will
124
+        // likely prevent preview and export (e.g. canvas too big)
125
+        return canvasToBlob(canvas).then(() => {
126 126
           renderPreview(canvas, previewNode);
127
-        })
128
-        .catch((error) => {
129
-          console.error(error);
130
-          renderPreview(new CanvasError(), previewNode);
131 127
         });
132
-    } catch (error) {
133
-      console.error(error);
134
-      renderPreview(new CanvasError(), previewNode);
135
-    }
128
+      })
129
+      .catch((error) => {
130
+        console.error(error);
131
+        renderPreview(new CanvasError(), previewNode);
132
+      });
136 133
   }, [
137 134
     appState,
135
+    files,
138 136
     exportedElements,
139 137
     exportBackground,
140 138
     exportPadding,
@@ -220,6 +218,7 @@ const ImageExportModal = ({
220 218
 export const ImageExportDialog = ({
221 219
   elements,
222 220
   appState,
221
+  files,
223 222
   exportPadding = DEFAULT_EXPORT_PADDING,
224 223
   actionManager,
225 224
   onExportToPng,
@@ -228,6 +227,7 @@ export const ImageExportDialog = ({
228 227
 }: {
229 228
   appState: AppState;
230 229
   elements: readonly NonDeletedExcalidrawElement[];
230
+  files: BinaryFiles;
231 231
   exportPadding?: number;
232 232
   actionManager: ActionsManagerInterface;
233 233
   onExportToPng: ExportCB;
@@ -258,6 +258,7 @@ export const ImageExportDialog = ({
258 258
           <ImageExportModal
259 259
             elements={elements}
260 260
             appState={appState}
261
+            files={files}
261 262
             exportPadding={exportPadding}
262 263
             actionManager={actionManager}
263 264
             onExportToPng={onExportToPng}

+ 11
- 4
src/components/JSONExportDialog.tsx View File

@@ -3,7 +3,7 @@ import { ActionsManagerInterface } from "../actions/types";
3 3
 import { NonDeletedExcalidrawElement } from "../element/types";
4 4
 import { t } from "../i18n";
5 5
 import { useIsMobile } from "./App";
6
-import { AppState, ExportOpts } from "../types";
6
+import { AppState, ExportOpts, BinaryFiles } from "../types";
7 7
 import { Dialog } from "./Dialog";
8 8
 import { exportFile, exportToFileIcon, link } from "./icons";
9 9
 import { ToolButton } from "./ToolButton";
@@ -21,11 +21,13 @@ export type ExportCB = (
21 21
 const JSONExportModal = ({
22 22
   elements,
23 23
   appState,
24
+  files,
24 25
   actionManager,
25 26
   exportOpts,
26 27
   canvas,
27 28
 }: {
28 29
   appState: AppState;
30
+  files: BinaryFiles;
29 31
   elements: readonly NonDeletedExcalidrawElement[];
30 32
   actionManager: ActionsManagerInterface;
31 33
   onCloseRequest: () => void;
@@ -68,12 +70,14 @@ const JSONExportModal = ({
68 70
               title={t("exportDialog.link_button")}
69 71
               aria-label={t("exportDialog.link_button")}
70 72
               showAriaLabel={true}
71
-              onClick={() => onExportToBackend(elements, appState, canvas)}
73
+              onClick={() =>
74
+                onExportToBackend(elements, appState, files, canvas)
75
+              }
72 76
             />
73 77
           </Card>
74 78
         )}
75 79
         {exportOpts.renderCustomUI &&
76
-          exportOpts.renderCustomUI(elements, appState, canvas)}
80
+          exportOpts.renderCustomUI(elements, appState, files, canvas)}
77 81
       </div>
78 82
     </div>
79 83
   );
@@ -82,12 +86,14 @@ const JSONExportModal = ({
82 86
 export const JSONExportDialog = ({
83 87
   elements,
84 88
   appState,
89
+  files,
85 90
   actionManager,
86 91
   exportOpts,
87 92
   canvas,
88 93
 }: {
89
-  appState: AppState;
90 94
   elements: readonly NonDeletedExcalidrawElement[];
95
+  appState: AppState;
96
+  files: BinaryFiles;
91 97
   actionManager: ActionsManagerInterface;
92 98
   exportOpts: ExportOpts;
93 99
   canvas: HTMLCanvasElement | null;
@@ -116,6 +122,7 @@ export const JSONExportDialog = ({
116 122
           <JSONExportModal
117 123
             elements={elements}
118 124
             appState={appState}
125
+            files={files}
119 126
             actionManager={actionManager}
120 127
             onCloseRequest={handleClose}
121 128
             exportOpts={exportOpts}

+ 39
- 7
src/components/LayerUI.tsx View File

@@ -20,6 +20,7 @@ import {
20 20
   AppProps,
21 21
   AppState,
22 22
   ExcalidrawProps,
23
+  BinaryFiles,
23 24
   LibraryItem,
24 25
   LibraryItems,
25 26
 } from "../types";
@@ -53,6 +54,7 @@ import { isImageFileHandle } from "../data/blob";
53 54
 interface LayerUIProps {
54 55
   actionManager: ActionManager;
55 56
   appState: AppState;
57
+  files: BinaryFiles;
56 58
   canvas: HTMLCanvasElement | null;
57 59
   setAppState: React.Component<any, AppState>["setState"];
58 60
   elements: readonly NonDeletedExcalidrawElement[];
@@ -76,6 +78,7 @@ interface LayerUIProps {
76 78
   focusContainer: () => void;
77 79
   library: Library;
78 80
   id: string;
81
+  onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
79 82
 }
80 83
 
81 84
 const useOnClickOutside = (
@@ -118,6 +121,7 @@ const LibraryMenuItems = ({
118 121
   libraryReturnUrl,
119 122
   focusContainer,
120 123
   library,
124
+  files,
121 125
   id,
122 126
 }: {
123 127
   libraryItems: LibraryItems;
@@ -126,6 +130,7 @@ const LibraryMenuItems = ({
126 130
   onInsertShape: (elements: LibraryItem) => void;
127 131
   onAddToLibrary: (elements: LibraryItem) => void;
128 132
   theme: AppState["theme"];
133
+  files: BinaryFiles;
129 134
   setAppState: React.Component<any, AppState>["setState"];
130 135
   setLibraryItems: (library: LibraryItems) => void;
131 136
   libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
@@ -221,6 +226,7 @@ const LibraryMenuItems = ({
221 226
         <Stack.Col key={x}>
222 227
           <LibraryUnit
223 228
             elements={libraryItems[y + x]}
229
+            files={files}
224 230
             pendingElements={
225 231
               shouldAddPendingElements ? pendingElements : undefined
226 232
             }
@@ -255,6 +261,7 @@ const LibraryMenu = ({
255 261
   onAddToLibrary,
256 262
   theme,
257 263
   setAppState,
264
+  files,
258 265
   libraryReturnUrl,
259 266
   focusContainer,
260 267
   library,
@@ -265,6 +272,7 @@ const LibraryMenu = ({
265 272
   onInsertShape: (elements: LibraryItem) => void;
266 273
   onAddToLibrary: () => void;
267 274
   theme: AppState["theme"];
275
+  files: BinaryFiles;
268 276
   setAppState: React.Component<any, AppState>["setState"];
269 277
   libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
270 278
   focusContainer: () => void;
@@ -286,12 +294,12 @@ const LibraryMenu = ({
286 294
     "preloading" | "loading" | "ready"
287 295
   >("preloading");
288 296
 
289
-  const loadingTimerRef = useRef<NodeJS.Timeout | null>(null);
297
+  const loadingTimerRef = useRef<number | null>(null);
290 298
 
291 299
   useEffect(() => {
292 300
     Promise.race([
293 301
       new Promise((resolve) => {
294
-        loadingTimerRef.current = setTimeout(() => {
302
+        loadingTimerRef.current = window.setTimeout(() => {
295 303
           resolve("loading");
296 304
         }, 100);
297 305
       }),
@@ -324,6 +332,12 @@ const LibraryMenu = ({
324 332
 
325 333
   const addToLibrary = useCallback(
326 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
+
327 341
       const items = await library.loadLibrary();
328 342
       const nextItems = [...items, elements];
329 343
       onAddToLibrary();
@@ -355,6 +369,7 @@ const LibraryMenu = ({
355 369
           focusContainer={focusContainer}
356 370
           library={library}
357 371
           theme={theme}
372
+          files={files}
358 373
           id={id}
359 374
         />
360 375
       )}
@@ -365,6 +380,7 @@ const LibraryMenu = ({
365 380
 const LayerUI = ({
366 381
   actionManager,
367 382
   appState,
383
+  files,
368 384
   setAppState,
369 385
   canvas,
370 386
   elements,
@@ -384,6 +400,7 @@ const LayerUI = ({
384 400
   focusContainer,
385 401
   library,
386 402
   id,
403
+  onImageAction,
387 404
 }: LayerUIProps) => {
388 405
   const isMobile = useIsMobile();
389 406
 
@@ -396,6 +413,7 @@ const LayerUI = ({
396 413
       <JSONExportDialog
397 414
         elements={elements}
398 415
         appState={appState}
416
+        files={files}
399 417
         actionManager={actionManager}
400 418
         exportOpts={UIOptions.canvasActions.export}
401 419
         canvas={canvas}
@@ -411,11 +429,17 @@ const LayerUI = ({
411 429
     const createExporter = (type: ExportType): ExportCB => async (
412 430
       exportedElements,
413 431
     ) => {
414
-      const fileHandle = await exportCanvas(type, exportedElements, appState, {
415
-        exportBackground: appState.exportBackground,
416
-        name: appState.name,
417
-        viewBackgroundColor: appState.viewBackgroundColor,
418
-      })
432
+      const fileHandle = await exportCanvas(
433
+        type,
434
+        exportedElements,
435
+        appState,
436
+        files,
437
+        {
438
+          exportBackground: appState.exportBackground,
439
+          name: appState.name,
440
+          viewBackgroundColor: appState.viewBackgroundColor,
441
+        },
442
+      )
419 443
         .catch(muteFSAbortError)
420 444
         .catch((error) => {
421 445
           console.error(error);
@@ -435,6 +459,7 @@ const LayerUI = ({
435 459
       <ImageExportDialog
436 460
         elements={elements}
437 461
         appState={appState}
462
+        files={files}
438 463
         actionManager={actionManager}
439 464
         onExportToPng={createExporter("png")}
440 465
         onExportToSvg={createExporter("svg")}
@@ -561,6 +586,7 @@ const LayerUI = ({
561 586
       focusContainer={focusContainer}
562 587
       library={library}
563 588
       theme={appState.theme}
589
+      files={files}
564 590
       id={id}
565 591
     />
566 592
   ) : null;
@@ -605,6 +631,11 @@ const LayerUI = ({
605 631
                           canvas={canvas}
606 632
                           elementType={appState.elementType}
607 633
                           setAppState={setAppState}
634
+                          onImageAction={({ pointerType }) => {
635
+                            onImageAction({
636
+                              insertOnCanvasDirectly: pointerType !== "mouse",
637
+                            });
638
+                          }}
608 639
                         />
609 640
                       </Stack.Row>
610 641
                     </Island>
@@ -765,6 +796,7 @@ const LayerUI = ({
765 796
         renderCustomFooter={renderCustomFooter}
766 797
         viewModeEnabled={viewModeEnabled}
767 798
         showThemeBtn={showThemeBtn}
799
+        onImageAction={onImageAction}
768 800
         renderTopRightUI={renderTopRightUI}
769 801
       />
770 802
     </>

+ 2
- 2
src/components/LibraryButton.tsx View File

@@ -26,7 +26,7 @@ export const LibraryButton: React.FC<{
26 26
           "zen-mode-visibility--hidden": appState.zenModeEnabled,
27 27
         },
28 28
       )}
29
-      title={`${capitalizeString(t("toolBar.library"))} — 9`}
29
+      title={`${capitalizeString(t("toolBar.library"))} — 0`}
30 30
       style={{ marginInlineStart: "var(--space-factor)" }}
31 31
     >
32 32
       <input
@@ -38,7 +38,7 @@ export const LibraryButton: React.FC<{
38 38
         }}
39 39
         checked={appState.isLibraryOpen}
40 40
         aria-label={capitalizeString(t("toolBar.library"))}
41
-        aria-keyshortcuts="9"
41
+        aria-keyshortcuts="0"
42 42
       />
43 43
       <div className="ToolIcon__icon">{LIBRARY_ICON}</div>
44 44
     </label>

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

@@ -6,7 +6,7 @@ import { MIME_TYPES } from "../constants";
6 6
 import { t } from "../i18n";
7 7
 import { useIsMobile } from "../components/App";
8 8
 import { exportToSvg } from "../scene/export";
9
-import { LibraryItem } from "../types";
9
+import { BinaryFiles, LibraryItem } from "../types";
10 10
 import "./LibraryUnit.scss";
11 11
 
12 12
 // fa-plus
@@ -21,44 +21,37 @@ const PLUS_ICON = (
21 21
 
22 22
 export const LibraryUnit = ({
23 23
   elements,
24
+  files,
24 25
   pendingElements,
25 26
   onRemoveFromLibrary,
26 27
   onClick,
27 28
 }: {
28 29
   elements?: LibraryItem;
30
+  files: BinaryFiles;
29 31
   pendingElements?: LibraryItem;
30 32
   onRemoveFromLibrary: () => void;
31 33
   onClick: () => void;
32 34
 }) => {
33 35
   const ref = useRef<HTMLDivElement | null>(null);
34 36
   useEffect(() => {
35
-    const elementsToRender = elements || pendingElements;
36
-    if (!elementsToRender) {
37
-      return;
38
-    }
39
-    let svg: SVGSVGElement;
40
-    const current = ref.current!;
41
-
42 37
     (async () => {
43
-      svg = await exportToSvg(elementsToRender, {
44
-        exportBackground: false,
45
-        viewBackgroundColor: oc.white,
46
-      });
47
-      for (const child of ref.current!.children) {
48
-        if (child.tagName !== "svg") {
49
-          continue;
50
-        }
51
-        current!.removeChild(child);
38
+      const elementsToRender = elements || pendingElements;
39
+      if (!elementsToRender) {
40
+        return;
52 41
       }
53
-      current!.appendChild(svg);
54
-    })();
55
-
56
-    return () => {
57
-      if (svg) {
58
-        current.removeChild(svg);
42
+      const svg = await exportToSvg(
43
+        elementsToRender,
44
+        {
45
+          exportBackground: false,
46
+          viewBackgroundColor: oc.white,
47
+        },
48
+        files,
49
+      );
50
+      if (ref.current) {
51
+        ref.current.innerHTML = svg.outerHTML;
59 52
       }
60
-    };
61
-  }, [elements, pendingElements]);
53
+    })();
54
+  }, [elements, pendingElements, files]);
62 55
 
63 56
   const [isHovered, setIsHovered] = useState(false);
64 57
   const isMobile = useIsMobile();

+ 7
- 0
src/components/MobileMenu.tsx View File

@@ -33,6 +33,7 @@ type MobileMenuProps = {
33 33
   renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
34 34
   viewModeEnabled: boolean;
35 35
   showThemeBtn: boolean;
36
+  onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
36 37
   renderTopRightUI?: (
37 38
     isMobile: boolean,
38 39
     appState: AppState,
@@ -54,6 +55,7 @@ export const MobileMenu = ({
54 55
   renderCustomFooter,
55 56
   viewModeEnabled,
56 57
   showThemeBtn,
58
+  onImageAction,
57 59
   renderTopRightUI,
58 60
 }: MobileMenuProps) => {
59 61
   const renderToolbar = () => {
@@ -70,6 +72,11 @@ export const MobileMenu = ({
70 72
                       canvas={canvas}
71 73
                       elementType={appState.elementType}
72 74
                       setAppState={setAppState}
75
+                      onImageAction={({ pointerType }) => {
76
+                        onImageAction({
77
+                          insertOnCanvasDirectly: pointerType !== "mouse",
78
+                        });
79
+                      }}
73 80
                     />
74 81
                   </Stack.Row>
75 82
                 </Island>

+ 8
- 4
src/components/PasteChartDialog.tsx View File

@@ -38,10 +38,14 @@ const ChartPreviewBtn = (props: {
38 38
     const previewNode = previewRef.current!;
39 39
 
40 40
     (async () => {
41
-      svg = await exportToSvg(elements, {
42
-        exportBackground: false,
43
-        viewBackgroundColor: oc.white,
44
-      });
41
+      svg = await exportToSvg(
42
+        elements,
43
+        {
44
+          exportBackground: false,
45
+          viewBackgroundColor: oc.white,
46
+        },
47
+        null, // files
48
+      );
45 49
 
46 50
       previewNode.appendChild(svg);
47 51
 

+ 48
- 0
src/components/Spinner.scss View File

@@ -0,0 +1,48 @@
1
+@import "open-color/open-color.scss";
2
+
3
+$duration: 1.6s;
4
+
5
+.excalidraw {
6
+  .Spinner {
7
+    display: flex;
8
+    align-items: center;
9
+    justify-content: center;
10
+    height: 100%;
11
+    margin-left: auto;
12
+    margin-right: auto;
13
+
14
+    --spinner-color: var(--icon-fill-color);
15
+
16
+    svg {
17
+      animation: rotate $duration linear infinite;
18
+      transform-origin: center center;
19
+    }
20
+
21
+    circle {
22
+      stroke: var(--spinner-color);
23
+      animation: dash $duration linear 0s infinite;
24
+      stroke-linecap: round;
25
+    }
26
+  }
27
+
28
+  @keyframes rotate {
29
+    100% {
30
+      transform: rotate(360deg);
31
+    }
32
+  }
33
+
34
+  @keyframes dash {
35
+    0% {
36
+      stroke-dasharray: 1, 300;
37
+      stroke-dashoffset: 0;
38
+    }
39
+    50% {
40
+      stroke-dasharray: 150, 300;
41
+      stroke-dashoffset: -200;
42
+    }
43
+    100% {
44
+      stroke-dasharray: 1, 300;
45
+      stroke-dashoffset: -280;
46
+    }
47
+  }
48
+}

+ 28
- 0
src/components/Spinner.tsx View File

@@ -0,0 +1,28 @@
1
+import React from "react";
2
+
3
+import "./Spinner.scss";
4
+
5
+const Spinner = ({
6
+  size = "1em",
7
+  circleWidth = 8,
8
+}: {
9
+  size?: string | number;
10
+  circleWidth?: number;
11
+}) => {
12
+  return (
13
+    <div className="Spinner">
14
+      <svg viewBox="0 0 100 100" style={{ width: size, height: size }}>
15
+        <circle
16
+          cx="50"
17
+          cy="50"
18
+          r={50 - circleWidth / 2}
19
+          strokeWidth={circleWidth}
20
+          fill="none"
21
+          strokeMiterlimit="10"
22
+        />
23
+      </svg>
24
+    </div>
25
+  );
26
+};
27
+
28
+export default Spinner;

+ 58
- 7
src/components/ToolButton.tsx View File

@@ -1,8 +1,11 @@
1 1
 import "./ToolIcon.scss";
2 2
 
3
-import React from "react";
3
+import React, { useEffect, useRef, useState } from "react";
4 4
 import clsx from "clsx";
5 5
 import { useExcalidrawContainer } from "./App";
6
+import { AbortError } from "../errors";
7
+import Spinner from "./Spinner";
8
+import { PointerType } from "../element/types";
6 9
 
7 10
 export type ToolButtonSize = "small" | "medium";
8 11
 
@@ -28,7 +31,7 @@ type ToolButtonProps =
28 31
   | (ToolButtonBaseProps & {
29 32
       type: "button";
30 33
       children?: React.ReactNode;
31
-      onClick?(): void;
34
+      onClick?(event: React.MouseEvent): void;
32 35
     })
33 36
   | (ToolButtonBaseProps & {
34 37
       type: "icon";
@@ -38,7 +41,7 @@ type ToolButtonProps =
38 41
   | (ToolButtonBaseProps & {
39 42
       type: "radio";
40 43
       checked: boolean;
41
-      onChange?(): void;
44
+      onChange?(data: { pointerType: PointerType | null }): void;
42 45
     });
43 46
 
44 47
 export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
@@ -47,6 +50,38 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
47 50
   React.useImperativeHandle(ref, () => innerRef.current);
48 51
   const sizeCn = `ToolIcon_size_${props.size}`;
49 52
 
53
+  const [isLoading, setIsLoading] = useState(false);
54
+
55
+  const isMountedRef = useRef(true);
56
+
57
+  const onClick = async (event: React.MouseEvent) => {
58
+    const ret = "onClick" in props && props.onClick?.(event);
59
+
60
+    if (ret && "then" in ret) {
61
+      try {
62
+        setIsLoading(true);
63
+        await ret;
64
+      } catch (error) {
65
+        if (!(error instanceof AbortError)) {
66
+          throw error;
67
+        }
68
+      } finally {
69
+        if (isMountedRef.current) {
70
+          setIsLoading(false);
71
+        }
72
+      }
73
+    }
74
+  };
75
+
76
+  useEffect(
77
+    () => () => {
78
+      isMountedRef.current = false;
79
+    },
80
+    [],
81
+  );
82
+
83
+  const lastPointerTypeRef = useRef<PointerType | null>(null);
84
+
50 85
   if (props.type === "button" || props.type === "icon") {
51 86
     return (
52 87
       <button
@@ -68,8 +103,9 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
68 103
         title={props.title}
69 104
         aria-label={props["aria-label"]}
70 105
         type="button"
71
-        onClick={props.onClick}
106
+        onClick={onClick}
72 107
         ref={innerRef}
108
+        disabled={isLoading}
73 109
       >
74 110
         {(props.icon || props.label) && (
75 111
           <div className="ToolIcon__icon" aria-hidden="true">
@@ -82,7 +118,9 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
82 118
           </div>
83 119
         )}
84 120
         {props.showAriaLabel && (
85
-          <div className="ToolIcon__label">{props["aria-label"]}</div>
121
+          <div className="ToolIcon__label">
122
+            {props["aria-label"]} {isLoading && <Spinner />}
123
+          </div>
86 124
         )}
87 125
         {props.children}
88 126
       </button>
@@ -90,7 +128,18 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
90 128
   }
91 129
 
92 130
   return (
93
-    <label className={clsx("ToolIcon", props.className)} title={props.title}>
131
+    <label
132
+      className={clsx("ToolIcon", props.className)}
133
+      title={props.title}
134
+      onPointerDown={(event) => {
135
+        lastPointerTypeRef.current = event.pointerType || null;
136
+      }}
137
+      onPointerUp={() => {
138
+        requestAnimationFrame(() => {
139
+          lastPointerTypeRef.current = null;
140
+        });
141
+      }}
142
+    >
94 143
       <input
95 144
         className={`ToolIcon_type_radio ${sizeCn}`}
96 145
         type="radio"
@@ -99,7 +148,9 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
99 148
         aria-keyshortcuts={props["aria-keyshortcuts"]}
100 149
         data-testid={props["data-testid"]}
101 150
         id={`${excalId}-${props.id}`}
102
-        onChange={props.onChange}
151
+        onChange={() => {
152
+          props.onChange?.({ pointerType: lastPointerTypeRef.current });
153
+        }}
103 154
         checked={props.checked}
104 155
         ref={innerRef}
105 156
       />

+ 6
- 0
src/components/ToolIcon.scss View File

@@ -54,10 +54,16 @@
54 54
   }
55 55
 
56 56
   .ToolIcon__label {
57
+    display: flex;
58
+    align-items: center;
57 59
     color: var(--icon-fill-color);
58 60
     font-family: var(--ui-font);
59 61
     margin: 0 0.8em;
60 62
     text-overflow: ellipsis;
63
+
64
+    .Spinner {
65
+      margin-left: 0.6em;
66
+    }
61 67
   }
62 68
 
63 69
   .ToolIcon_size_small .ToolIcon__icon {

+ 20
- 0
src/constants.ts View File

@@ -90,6 +90,12 @@ export const GRID_SIZE = 20; // TODO make it configurable?
90 90
 export const MIME_TYPES = {
91 91
   excalidraw: "application/vnd.excalidraw+json",
92 92
   excalidrawlib: "application/vnd.excalidrawlib+json",
93
+  json: "application/json",
94
+  svg: "image/svg+xml",
95
+  png: "image/png",
96
+  jpg: "image/jpeg",
97
+  gif: "image/gif",
98
+  binary: "application/octet-stream",
93 99
 } as const;
94 100
 
95 101
 export const EXPORT_DATA_TYPES = {
@@ -105,6 +111,7 @@ export const STORAGE_KEYS = {
105 111
 } as const;
106 112
 
107 113
 // time in milliseconds
114
+export const IMAGE_RENDER_TIMEOUT = 500;
108 115
 export const TAP_TWICE_TIMEOUT = 300;
109 116
 export const TOUCH_CTX_MENU_TIMEOUT = 500;
110 117
 export const TITLE_TIMEOUT = 10000;
@@ -154,3 +161,16 @@ export const MAX_DECIMALS_FOR_SVG_EXPORT = 2;
154 161
 
155 162
 export const EXPORT_SCALES = [1, 2, 3];
156 163
 export const DEFAULT_EXPORT_PADDING = 10; // px
164
+
165
+export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
166
+
167
+export const ALLOWED_IMAGE_MIME_TYPES = [
168
+  MIME_TYPES.png,
169
+  MIME_TYPES.jpg,
170
+  MIME_TYPES.svg,
171
+  MIME_TYPES.gif,
172
+] as const;
173
+
174
+export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
175
+
176
+export const SVG_NS = "http://www.w3.org/2000/svg";

+ 130
- 13
src/data/blob.ts View File

@@ -1,11 +1,16 @@
1
+import { nanoid } from "nanoid";
1 2
 import { cleanAppStateForExport } from "../appState";
2
-import { EXPORT_DATA_TYPES } from "../constants";
3
+import {
4
+  ALLOWED_IMAGE_MIME_TYPES,
5
+  EXPORT_DATA_TYPES,
6
+  MIME_TYPES,
7
+} from "../constants";
3 8
 import { clearElementsForExport } from "../element";
4
-import { ExcalidrawElement } from "../element/types";
9
+import { ExcalidrawElement, FileId } from "../element/types";
5 10
 import { CanvasError } from "../errors";
6 11
 import { t } from "../i18n";
7 12
 import { calculateScrollCenter } from "../scene";
8
-import { AppState } from "../types";
13
+import { AppState, DataURL } from "../types";
9 14
 import { FileSystemHandle } from "./filesystem";
10 15
 import { isValidExcalidrawData } from "./json";
11 16
 import { restore } from "./restore";
@@ -14,16 +19,22 @@ import { ImportedLibraryData } from "./types";
14 19
 const parseFileContents = async (blob: Blob | File) => {
15 20
   let contents: string;
16 21
 
17
-  if (blob.type === "image/png") {
22
+  if (blob.type === MIME_TYPES.png) {
18 23
     try {
19 24
       return await (
20 25
         await import(/* webpackChunkName: "image" */ "./image")
21 26
       ).decodePngMetadata(blob);
22 27
     } catch (error) {
23 28
       if (error.message === "INVALID") {
24
-        throw new Error(t("alerts.imageDoesNotContainScene"));
29
+        throw new DOMException(
30
+          t("alerts.imageDoesNotContainScene"),
31
+          "EncodingError",
32
+        );
25 33
       } else {
26
-        throw new Error(t("alerts.cannotRestoreFromImage"));
34
+        throw new DOMException(
35
+          t("alerts.cannotRestoreFromImage"),
36
+          "EncodingError",
37
+        );
27 38
       }
28 39
     }
29 40
   } else {
@@ -40,7 +51,7 @@ const parseFileContents = async (blob: Blob | File) => {
40 51
         };
41 52
       });
42 53
     }
43
-    if (blob.type === "image/svg+xml") {
54
+    if (blob.type === MIME_TYPES.svg) {
44 55
       try {
45 56
         return await (
46 57
           await import(/* webpackChunkName: "image" */ "./image")
@@ -49,9 +60,15 @@ const parseFileContents = async (blob: Blob | File) => {
49 60
         });
50 61
       } catch (error) {
51 62
         if (error.message === "INVALID") {
52
-          throw new Error(t("alerts.imageDoesNotContainScene"));
63
+          throw new DOMException(
64
+            t("alerts.imageDoesNotContainScene"),
65
+            "EncodingError",
66
+          );
53 67
         } else {
54
-          throw new Error(t("alerts.cannotRestoreFromImage"));
68
+          throw new DOMException(
69
+            t("alerts.cannotRestoreFromImage"),
70
+            "EncodingError",
71
+          );
55 72
         }
56 73
       }
57 74
     }
@@ -70,13 +87,13 @@ export const getMimeType = (blob: Blob | string): string => {
70 87
     name = blob.name || "";
71 88
   }
72 89
   if (/\.(excalidraw|json)$/.test(name)) {
73
-    return "application/json";
90
+    return MIME_TYPES.json;
74 91
   } else if (/\.png$/.test(name)) {
75
-    return "image/png";
92
+    return MIME_TYPES.png;
76 93
   } else if (/\.jpe?g$/.test(name)) {
77
-    return "image/jpeg";
94
+    return MIME_TYPES.jpg;
78 95
   } else if (/\.svg$/.test(name)) {
79
-    return "image/svg+xml";
96
+    return MIME_TYPES.svg;
80 97
   }
81 98
   return "";
82 99
 };
@@ -100,6 +117,15 @@ export const isImageFileHandle = (handle: FileSystemHandle | null) => {
100 117
   return type === "png" || type === "svg";
101 118
 };
102 119
 
120
+export const isSupportedImageFile = (
121
+  blob: Blob | null | undefined,
122
+): blob is Blob & { type: typeof ALLOWED_IMAGE_MIME_TYPES[number] } => {
123
+  const { type } = blob || {};
124
+  return (
125
+    !!type && (ALLOWED_IMAGE_MIME_TYPES as readonly string[]).includes(type)
126
+  );
127
+};
128
+
103 129
 export const loadFromBlob = async (
104 130
   blob: Blob,
105 131
   /** @see restore.localAppState */
@@ -123,6 +149,7 @@ export const loadFromBlob = async (
123 149
             ? calculateScrollCenter(data.elements || [], localAppState, null)
124 150
             : {}),
125 151
         },
152
+        files: data.files,
126 153
       },
127 154
       localAppState,
128 155
       localElements,
@@ -165,3 +192,93 @@ export const canvasToBlob = async (
165 192
     }
166 193
   });
167 194
 };
195
+
196
+/** generates SHA-1 digest from supplied file (if not supported, falls back
197
+    to a 40-char base64 random id) */
198
+export const generateIdFromFile = async (file: File) => {
199
+  let id: FileId;
200
+  try {
201
+    const hashBuffer = await window.crypto.subtle.digest(
202
+      "SHA-1",
203
+      await file.arrayBuffer(),
204
+    );
205
+    id =
206
+      // convert buffer to byte array
207
+      Array.from(new Uint8Array(hashBuffer))
208
+        // convert to hex string
209
+        .map((byte) => byte.toString(16).padStart(2, "0"))
210
+        .join("") as FileId;
211
+  } catch (error) {
212
+    console.error(error);
213
+    // length 40 to align with the HEX length of SHA-1 (which is 160 bit)
214
+    id = nanoid(40) as FileId;
215
+  }
216
+
217
+  return id;
218
+};
219
+
220
+export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
221
+  return new Promise((resolve, reject) => {
222
+    const reader = new FileReader();
223
+    reader.onload = () => {
224
+      const dataURL = reader.result as DataURL;
225
+      resolve(dataURL);
226
+    };
227
+    reader.onerror = (error) => reject(error);
228
+    reader.readAsDataURL(file);
229
+  });
230
+};
231
+
232
+export const dataURLToFile = (dataURL: DataURL, filename = "") => {
233
+  const dataIndexStart = dataURL.indexOf(",");
234
+  const byteString = atob(dataURL.slice(dataIndexStart + 1));
235
+  const mimeType = dataURL.slice(0, dataIndexStart).split(":")[1].split(";")[0];
236
+
237
+  const ab = new ArrayBuffer(byteString.length);
238
+  const ia = new Uint8Array(ab);
239
+  for (let i = 0; i < byteString.length; i++) {
240
+    ia[i] = byteString.charCodeAt(i);
241
+  }
242
+  return new File([ab], filename, { type: mimeType });
243
+};
244
+
245
+export const resizeImageFile = async (
246
+  file: File,
247
+  maxWidthOrHeight: number,
248
+): Promise<File> => {
249
+  // SVG files shouldn't a can't be resized
250
+  if (file.type === MIME_TYPES.svg) {
251
+    return file;
252
+  }
253
+
254
+  const [pica, imageBlobReduce] = await Promise.all([
255
+    import("pica").then((res) => res.default),
256
+    // a wrapper for pica for better API
257
+    import("image-blob-reduce").then((res) => res.default),
258
+  ]);
259
+
260
+  // CRA's minification settings break pica in WebWorkers, so let's disable
261
+  // them for now
262
+  // https://github.com/nodeca/image-blob-reduce/issues/21#issuecomment-757365513
263
+  const reduce = imageBlobReduce({
264
+    pica: pica({ features: ["js", "wasm"] }),
265
+  });
266
+
267
+  const fileType = file.type;
268
+
269
+  if (!isSupportedImageFile(file)) {
270
+    throw new Error(t("errors.unsupportedFileType"));
271
+  }
272
+
273
+  return new File(
274
+    [await reduce.toBlob(file, { max: maxWidthOrHeight })],
275
+    file.name,
276
+    { type: fileType },
277
+  );
278
+};
279
+
280
+export const SVGStringToFile = (SVGString: string, filename: string = "") => {
281
+  return new File([new TextEncoder().encode(SVGString)], filename, {
282
+    type: MIME_TYPES.svg,
283
+  }) as File & { type: typeof MIME_TYPES.svg };
284
+};

+ 267
- 4
src/data/encode.ts View File

@@ -1,16 +1,19 @@
1 1
 import { deflate, inflate } from "pako";
2
+import { encryptData, decryptData } from "./encryption";
2 3
 
3 4
 // -----------------------------------------------------------------------------
4 5
 // byte (binary) strings
5 6
 // -----------------------------------------------------------------------------
6 7
 
7 8
 // fast, Buffer-compatible implem
8
-export const toByteString = (data: string | Uint8Array): Promise<string> => {
9
+export const toByteString = (
10
+  data: string | Uint8Array | ArrayBuffer,
11
+): Promise<string> => {
9 12
   return new Promise((resolve, reject) => {
10 13
     const blob =
11 14
       typeof data === "string"
12 15
         ? new Blob([new TextEncoder().encode(data)])
13
-        : new Blob([data]);
16
+        : new Blob([data instanceof Uint8Array ? data : new Uint8Array(data)]);
14 17
     const reader = new FileReader();
15 18
     reader.onload = (event) => {
16 19
       if (!event.target || typeof event.target.result !== "string") {
@@ -44,12 +47,14 @@ const byteStringToString = (byteString: string) => {
44 47
  *  due to reencoding
45 48
  */
46 49
 export const stringToBase64 = async (str: string, isByteString = false) => {
47
-  return isByteString ? btoa(str) : btoa(await toByteString(str));
50
+  return isByteString ? window.btoa(str) : window.btoa(await toByteString(str));
48 51
 };
49 52
 
50 53
 // async to align with stringToBase64
51 54
 export const base64ToString = async (base64: string, isByteString = false) => {
52
-  return isByteString ? atob(base64) : byteStringToString(atob(base64));
55
+  return isByteString
56
+    ? window.atob(base64)
57
+    : byteStringToString(window.atob(base64));
53 58
 };
54 59
 
55 60
 // -----------------------------------------------------------------------------
@@ -114,3 +119,261 @@ export const decode = async (data: EncodedData): Promise<string> => {
114 119
 
115 120
   return decoded;
116 121
 };
122
+
123
+// -----------------------------------------------------------------------------
124
+// binary encoding
125
+// -----------------------------------------------------------------------------
126
+
127
+type FileEncodingInfo = {
128
+  /* version 2 is the version we're shipping the initial image support with.
129
+    version 1 was a PR version that a lot of people were using anyway.
130
+    Thus, if there are issues we can check whether they're not using the
131
+    unoffic version */
132
+  version: 1 | 2;
133
+  compression: "pako@1" | null;
134
+  encryption: "AES-GCM" | null;
135
+};
136
+
137
+// -----------------------------------------------------------------------------
138
+const CONCAT_BUFFERS_VERSION = 1;
139
+/** how many bytes we use to encode how many bytes the next chunk has.
140
+ * Corresponds to DataView setter methods (setUint32, setUint16, etc).
141
+ *
142
+ * NOTE ! values must not be changed, which would be backwards incompatible !
143
+ */
144
+const VERSION_DATAVIEW_BYTES = 4;
145
+const NEXT_CHUNK_SIZE_DATAVIEW_BYTES = 4;
146
+// -----------------------------------------------------------------------------
147
+
148
+const DATA_VIEW_BITS_MAP = { 1: 8, 2: 16, 4: 32 } as const;
149
+
150
+// getter
151
+function dataView(buffer: Uint8Array, bytes: 1 | 2 | 4, offset: number): number;
152
+// setter
153
+function dataView(
154
+  buffer: Uint8Array,
155
+  bytes: 1 | 2 | 4,
156
+  offset: number,
157
+  value: number,
158
+): Uint8Array;
159
+/**
160
+ * abstraction over DataView that serves as a typed getter/setter in case
161
+ * you're using constants for the byte size and want to ensure there's no
162
+ * discrepenancy in the encoding across refactors.
163
+ *
164
+ * DataView serves for an endian-agnostic handling of numbers in ArrayBuffers.
165
+ */
166
+function dataView(
167
+  buffer: Uint8Array,
168
+  bytes: 1 | 2 | 4,
169
+  offset: number,
170
+  value?: number,
171
+): Uint8Array | number {
172
+  if (value != null) {
173
+    if (value > Math.pow(2, DATA_VIEW_BITS_MAP[bytes]) - 1) {
174
+      throw new Error(
175
+        `attempting to set value higher than the allocated bytes (value: ${value}, bytes: ${bytes})`,
176
+      );
177
+    }
178
+    const method = `setUint${DATA_VIEW_BITS_MAP[bytes]}` as const;
179
+    new DataView(buffer.buffer)[method](offset, value);
180
+    return buffer;
181
+  }
182
+  const method = `getUint${DATA_VIEW_BITS_MAP[bytes]}` as const;
183
+  return new DataView(buffer.buffer)[method](offset);
184
+}
185
+
186
+// -----------------------------------------------------------------------------
187
+
188
+/**
189
+ * Resulting concatenated buffer has this format:
190
+ *
191
+ * [
192
+ *   VERSION chunk (4 bytes)
193
+ *   LENGTH chunk 1 (4 bytes)
194
+ *   DATA chunk 1 (up to 2^32 bits)
195
+ *   LENGTH chunk 2 (4 bytes)
196
+ *   DATA chunk 2 (up to 2^32 bits)
197
+ *   ...
198
+ * ]
199
+ *
200
+ * @param buffers each buffer (chunk) must be at most 2^32 bits large (~4GB)
201
+ */
202
+const concatBuffers = (...buffers: Uint8Array[]) => {
203
+  const bufferView = new Uint8Array(
204
+    VERSION_DATAVIEW_BYTES +
205
+      NEXT_CHUNK_SIZE_DATAVIEW_BYTES * buffers.length +
206
+      buffers.reduce((acc, buffer) => acc + buffer.byteLength, 0),
207
+  );
208
+
209
+  let cursor = 0;
210
+
211
+  // as the first chunk we'll encode the version for backwards compatibility
212
+  dataView(bufferView, VERSION_DATAVIEW_BYTES, cursor, CONCAT_BUFFERS_VERSION);
213
+  cursor += VERSION_DATAVIEW_BYTES;
214
+
215
+  for (const buffer of buffers) {
216
+    dataView(
217
+      bufferView,
218
+      NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
219
+      cursor,
220
+      buffer.byteLength,
221
+    );
222
+    cursor += NEXT_CHUNK_SIZE_DATAVIEW_BYTES;
223
+
224
+    bufferView.set(buffer, cursor);
225
+    cursor += buffer.byteLength;
226
+  }
227
+
228
+  return bufferView;
229
+};
230
+
231
+/** can only be used on buffers created via `concatBuffers()` */
232
+const splitBuffers = (concatenatedBuffer: Uint8Array) => {
233
+  const buffers = [];
234
+
235
+  let cursor = 0;
236
+
237
+  // first chunk is the version (ignored for now)
238
+  cursor += VERSION_DATAVIEW_BYTES;
239
+
240
+  while (true) {
241
+    const chunkSize = dataView(
242
+      concatenatedBuffer,
243
+      NEXT_CHUNK_SIZE_DATAVIEW_BYTES,
244
+      cursor,
245
+    );
246
+    cursor += NEXT_CHUNK_SIZE_DATAVIEW_BYTES;
247
+
248
+    buffers.push(concatenatedBuffer.slice(cursor, cursor + chunkSize));
249
+    cursor += chunkSize;
250
+    if (cursor >= concatenatedBuffer.byteLength) {
251
+      break;
252
+    }
253
+  }
254
+
255
+  return buffers;
256
+};
257
+
258
+// helpers for (de)compressing data with JSON metadata including encryption
259
+// -----------------------------------------------------------------------------
260
+
261
+/** @private */
262
+const _encryptAndCompress = async (
263
+  data: Uint8Array | string,
264
+  encryptionKey: string,
265
+) => {
266
+  const { encryptedBuffer, iv } = await encryptData(
267
+    encryptionKey,
268
+    deflate(data),
269
+  );
270
+
271
+  return { iv, buffer: new Uint8Array(encryptedBuffer) };
272
+};
273
+
274
+/**
275
+ * The returned buffer has following format:
276
+ * `[]` refers to a buffers wrapper (see `concatBuffers`)
277
+ *
278
+ * [
279
+ *   encodingMetadataBuffer,
280
+ *   iv,
281
+ *   [
282
+ *      contentsMetadataBuffer
283
+ *      contentsBuffer
284
+ *   ]
285
+ * ]
286
+ */
287
+export const compressData = async <T extends Record<string, any> = never>(
288
+  dataBuffer: Uint8Array,
289
+  options: {
290
+    encryptionKey: string;
291
+  } & ([T] extends [never]
292
+    ? {
293
+        metadata?: T;
294
+      }
295
+    : {
296
+        metadata: T;
297
+      }),
298
+): Promise<Uint8Array> => {
299
+  const fileInfo: FileEncodingInfo = {
300
+    version: 2,
301
+    compression: "pako@1",
302
+    encryption: "AES-GCM",
303
+  };
304
+
305
+  const encodingMetadataBuffer = new TextEncoder().encode(
306
+    JSON.stringify(fileInfo),
307
+  );
308
+
309
+  const contentsMetadataBuffer = new TextEncoder().encode(
310
+    JSON.stringify(options.metadata || null),
311
+  );
312
+
313
+  const { iv, buffer } = await _encryptAndCompress(
314
+    concatBuffers(contentsMetadataBuffer, dataBuffer),
315
+    options.encryptionKey,
316
+  );
317
+
318
+  return concatBuffers(encodingMetadataBuffer, iv, buffer);
319
+};
320
+
321
+/** @private */
322
+const _decryptAndDecompress = async (
323
+  iv: Uint8Array,
324
+  decryptedBuffer: Uint8Array,
325
+  decryptionKey: string,
326
+  isCompressed: boolean,
327
+) => {
328
+  decryptedBuffer = new Uint8Array(
329
+    await decryptData(iv, decryptedBuffer, decryptionKey),
330
+  );
331
+
332
+  if (isCompressed) {
333
+    return inflate(decryptedBuffer);
334
+  }
335
+
336
+  return decryptedBuffer;
337
+};
338
+
339
+export const decompressData = async <T extends Record<string, any>>(
340
+  bufferView: Uint8Array,
341
+  options: { decryptionKey: string },
342
+) => {
343
+  // first chunk is encoding metadata (ignored for now)
344
+  const [encodingMetadataBuffer, iv, buffer] = splitBuffers(bufferView);
345
+
346
+  const encodingMetadata: FileEncodingInfo = JSON.parse(
347
+    new TextDecoder().decode(encodingMetadataBuffer),
348
+  );
349
+
350
+  try {
351
+    const [contentsMetadataBuffer, contentsBuffer] = splitBuffers(
352
+      await _decryptAndDecompress(
353
+        iv,
354
+        buffer,
355
+        options.decryptionKey,
356
+        !!encodingMetadata.compression,
357
+      ),
358
+    );
359
+
360
+    const metadata = JSON.parse(
361
+      new TextDecoder().decode(contentsMetadataBuffer),
362
+    ) as T;
363
+
364
+    return {
365
+      /** metadata source is always JSON so we can decode it here */
366
+      metadata,
367
+      /** data can be anything so the caller must decode it */
368
+      data: contentsBuffer,
369
+    };
370
+  } catch (error) {
371
+    console.error(
372
+      `Error during decompressing and decrypting the file.`,
373
+      encodingMetadata,
374
+    );
375
+    throw error;
376
+  }
377
+};
378
+
379
+// -----------------------------------------------------------------------------

+ 79
- 0
src/data/encryption.ts View File

@@ -0,0 +1,79 @@
1
+export const IV_LENGTH_BYTES = 12;
2
+
3
+export const createIV = () => {
4
+  const arr = new Uint8Array(IV_LENGTH_BYTES);
5
+  return window.crypto.getRandomValues(arr);
6
+};
7
+
8
+export const generateEncryptionKey = async () => {
9
+  const key = await window.crypto.subtle.generateKey(
10
+    {
11
+      name: "AES-GCM",
12
+      length: 128,
13
+    },
14
+    true, // extractable
15
+    ["encrypt", "decrypt"],
16
+  );
17
+  return (await window.crypto.subtle.exportKey("jwk", key)).k;
18
+};
19
+
20
+export const getImportedKey = (key: string, usage: KeyUsage) =>
21
+  window.crypto.subtle.importKey(
22
+    "jwk",
23
+    {
24
+      alg: "A128GCM",
25
+      ext: true,
26
+      k: key,
27
+      key_ops: ["encrypt", "decrypt"],
28
+      kty: "oct",
29
+    },
30
+    {
31
+      name: "AES-GCM",
32
+      length: 128,
33
+    },
34
+    false, // extractable
35
+    [usage],
36
+  );
37
+
38
+export const encryptData = async (
39
+  key: string,
40
+  data: Uint8Array | ArrayBuffer | Blob | File | string,
41
+): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array }> => {
42
+  const importedKey = await getImportedKey(key, "encrypt");
43
+  const iv = createIV();
44
+  const buffer: ArrayBuffer | Uint8Array =
45
+    typeof data === "string"
46
+      ? new TextEncoder().encode(data)
47
+      : data instanceof Uint8Array
48
+      ? data
49
+      : data instanceof Blob
50
+      ? await data.arrayBuffer()
51
+      : data;
52
+
53
+  const encryptedBuffer = await window.crypto.subtle.encrypt(
54
+    {
55
+      name: "AES-GCM",
56
+      iv,
57
+    },
58
+    importedKey,
59
+    buffer as ArrayBuffer | Uint8Array,
60
+  );
61
+
62
+  return { encryptedBuffer, iv };
63
+};
64
+
65
+export const decryptData = async (
66
+  iv: Uint8Array,
67
+  encrypted: Uint8Array | ArrayBuffer,
68
+  privateKey: string,
69
+): Promise<ArrayBuffer> => {
70
+  const key = await getImportedKey(privateKey, "decrypt");
71
+  return window.crypto.subtle.decrypt(
72
+    {
73
+      name: "AES-GCM",
74
+      iv,
75
+    },
76
+    key,
77
+    encrypted,
78
+  );
79
+};

+ 2
- 10
src/data/filesystem.ts View File

@@ -10,6 +10,7 @@ import { AbortError } from "../errors";
10 10
 import { debounce } from "../utils";
11 11
 
12 12
 type FILE_EXTENSION =
13
+  | "gif"
13 14
   | "jpg"
14 15
   | "png"
15 16
   | "svg"
@@ -17,15 +18,6 @@ type FILE_EXTENSION =
17 18
   | "excalidraw"
18 19
   | "excalidrawlib";
19 20
 
20
-const FILE_TYPE_TO_MIME_TYPE: Record<FILE_EXTENSION, string> = {
21
-  jpg: "image/jpeg",
22
-  png: "image/png",
23
-  svg: "image/svg+xml",
24
-  json: "application/json",
25
-  excalidraw: MIME_TYPES.excalidraw,
26
-  excalidrawlib: MIME_TYPES.excalidrawlib,
27
-};
28
-
29 21
 const INPUT_CHANGE_INTERVAL_MS = 500;
30 22
 
31 23
 export const fileOpen = <M extends boolean | undefined = false>(opts: {
@@ -41,7 +33,7 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
41 33
     : FileWithHandle[];
42 34
 
43 35
   const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
44
-    mimeTypes.push(FILE_TYPE_TO_MIME_TYPE[type]);
36
+    mimeTypes.push(MIME_TYPES[type]);
45 37
 
46 38
     return mimeTypes;
47 39
   }, [] as string[]);

+ 1
- 1
src/data/image.ts View File

@@ -57,7 +57,7 @@ export const encodePngMetadata = async ({
57 57
   // insert metadata before last chunk (iEND)
58 58
   chunks.splice(-1, 0, metadataChunk);
59 59
 
60
-  return new Blob([encodePng(chunks)], { type: "image/png" });
60
+  return new Blob([encodePng(chunks)], { type: MIME_TYPES.png });
61 61
 };
62 62
 
63 63
 export const decodePngMetadata = async (blob: Blob) => {

+ 18
- 13
src/data/index.ts View File

@@ -2,12 +2,12 @@ import {
2 2
   copyBlobToClipboardAsPng,
3 3
   copyTextToSystemClipboard,
4 4
 } from "../clipboard";
5
-import { DEFAULT_EXPORT_PADDING } from "../constants";
5
+import { DEFAULT_EXPORT_PADDING, MIME_TYPES } from "../constants";
6 6
 import { NonDeletedExcalidrawElement } from "../element/types";
7 7
 import { t } from "../i18n";
8 8
 import { exportToCanvas, exportToSvg } from "../scene/export";
9 9
 import { ExportType } from "../scene/types";
10
-import { AppState } from "../types";
10
+import { AppState, BinaryFiles } from "../types";
11 11
 import { canvasToBlob } from "./blob";
12 12
 import { fileSave, FileSystemHandle } from "./filesystem";
13 13
 import { serializeAsJSON } from "./json";
@@ -19,6 +19,7 @@ export const exportCanvas = async (
19 19
   type: ExportType,
20 20
   elements: readonly NonDeletedExcalidrawElement[],
21 21
   appState: AppState,
22
+  files: BinaryFiles,
22 23
   {
23 24
     exportBackground,
24 25
     exportPadding = DEFAULT_EXPORT_PADDING,
@@ -37,17 +38,21 @@ export const exportCanvas = async (
37 38
     throw new Error(t("alerts.cannotExportEmptyCanvas"));
38 39
   }
39 40
   if (type === "svg" || type === "clipboard-svg") {
40
-    const tempSvg = await exportToSvg(elements, {
41
-      exportBackground,
42
-      exportWithDarkMode: appState.exportWithDarkMode,
43
-      viewBackgroundColor,
44
-      exportPadding,
45
-      exportScale: appState.exportScale,
46
-      exportEmbedScene: appState.exportEmbedScene && type === "svg",
47
-    });
41
+    const tempSvg = await exportToSvg(
42
+      elements,
43
+      {
44
+        exportBackground,
45
+        exportWithDarkMode: appState.exportWithDarkMode,
46
+        viewBackgroundColor,
47
+        exportPadding,
48
+        exportScale: appState.exportScale,
49
+        exportEmbedScene: appState.exportEmbedScene && type === "svg",
50
+      },
51
+      files,
52
+    );
48 53
     if (type === "svg") {
49 54
       return await fileSave(
50
-        new Blob([tempSvg.outerHTML], { type: "image/svg+xml" }),
55
+        new Blob([tempSvg.outerHTML], { type: MIME_TYPES.svg }),
51 56
         {
52 57
           name,
53 58
           extension: "svg",
@@ -60,7 +65,7 @@ export const exportCanvas = async (
60 65
     }
61 66
   }
62 67
 
63
-  const tempCanvas = exportToCanvas(elements, appState, {
68
+  const tempCanvas = await exportToCanvas(elements, appState, files, {
64 69
     exportBackground,
65 70
     viewBackgroundColor,
66 71
     exportPadding,
@@ -76,7 +81,7 @@ export const exportCanvas = async (
76 81
         await import(/* webpackChunkName: "image" */ "./image")
77 82
       ).encodePngMetadata({
78 83
         blob,
79
-        metadata: serializeAsJSON(elements, appState),
84
+        metadata: serializeAsJSON(elements, appState, files, "local"),
80 85
       });
81 86
     }
82 87
 

+ 42
- 15
src/data/json.ts View File

@@ -1,9 +1,9 @@
1 1
 import { fileOpen, fileSave } from "./filesystem";
2
-import { cleanAppStateForExport } from "../appState";
2
+import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
3 3
 import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
4
-import { clearElementsForExport } from "../element";
4
+import { clearElementsForDatabase, clearElementsForExport } from "../element";
5 5
 import { ExcalidrawElement } from "../element/types";
6
-import { AppState } from "../types";
6
+import { AppState, BinaryFiles } from "../types";
7 7
 import { isImageFileHandle, loadFromBlob } from "./blob";
8 8
 
9 9
 import {
@@ -13,16 +13,50 @@ import {
13 13
 } from "./types";
14 14
 import Library from "./library";
15 15
 
16
+/**
17
+ * Strips out files which are only referenced by deleted elements
18
+ */
19
+const filterOutDeletedFiles = (
20
+  elements: readonly ExcalidrawElement[],
21
+  files: BinaryFiles,
22
+) => {
23
+  const nextFiles: BinaryFiles = {};
24
+  for (const element of elements) {
25
+    if (
26
+      !element.isDeleted &&
27
+      "fileId" in element &&
28
+      element.fileId &&
29
+      files[element.fileId]
30
+    ) {
31
+      nextFiles[element.fileId] = files[element.fileId];
32
+    }
33
+  }
34
+  return nextFiles;
35
+};
36
+
16 37
 export const serializeAsJSON = (
17 38
   elements: readonly ExcalidrawElement[],
18 39
   appState: Partial<AppState>,
40
+  files: BinaryFiles,
41
+  type: "local" | "database",
19 42
 ): string => {
20 43
   const data: ExportedDataState = {
21 44
     type: EXPORT_DATA_TYPES.excalidraw,
22 45
     version: 2,
23 46
     source: EXPORT_SOURCE,
24
-    elements: clearElementsForExport(elements),
25
-    appState: cleanAppStateForExport(appState),
47
+    elements:
48
+      type === "local"
49
+        ? clearElementsForExport(elements)
50
+        : clearElementsForDatabase(elements),
51
+    appState:
52
+      type === "local"
53
+        ? cleanAppStateForExport(appState)
54
+        : clearAppStateForDatabase(appState),
55
+    files:
56
+      type === "local"
57
+        ? filterOutDeletedFiles(elements, files)
58
+        : // will be stripped from JSON
59
+          undefined,
26 60
   };
27 61
 
28 62
   return JSON.stringify(data, null, 2);
@@ -31,8 +65,9 @@ export const serializeAsJSON = (
31 65
 export const saveAsJSON = async (
32 66
   elements: readonly ExcalidrawElement[],
33 67
   appState: AppState,
68
+  files: BinaryFiles,
34 69
 ) => {
35
-  const serialized = serializeAsJSON(elements, appState);
70
+  const serialized = serializeAsJSON(elements, appState, files, "local");
36 71
   const blob = new Blob([serialized], {
37 72
     type: MIME_TYPES.excalidraw,
38 73
   });
@@ -56,15 +91,7 @@ export const loadFromJSON = async (
56 91
     description: "Excalidraw files",
57 92
     // ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
58 93
     // gets resolved. Else, iOS users cannot open `.excalidraw` files.
59
-    /*
60
-    extensions: [".json", ".excalidraw", ".png", ".svg"],
61
-    mimeTypes: [
62
-      MIME_TYPES.excalidraw,
63
-      "application/json",
64
-      "image/png",
65
-      "image/svg+xml",
66
-    ],
67
-    */
94
+    // extensions: ["json", "excalidraw", "png", "svg"],
68 95
   });
69 96
   return loadFromBlob(blob, localAppState, localElements);
70 97
 };

+ 3
- 1
src/data/resave.ts View File

@@ -1,5 +1,5 @@
1 1
 import { ExcalidrawElement } from "../element/types";
2
-import { AppState } from "../types";
2
+import { AppState, BinaryFiles } from "../types";
3 3
 import { exportCanvas } from ".";
4 4
 import { getNonDeletedElements } from "../element";
5 5
 import { getFileHandleType, isImageFileHandleType } from "./blob";
@@ -7,6 +7,7 @@ import { getFileHandleType, isImageFileHandleType } from "./blob";
7 7
 export const resaveAsImageWithScene = async (
8 8
   elements: readonly ExcalidrawElement[],
9 9
   appState: AppState,
10
+  files: BinaryFiles,
10 11
 ) => {
11 12
   const { exportBackground, viewBackgroundColor, name, fileHandle } = appState;
12 13
 
@@ -26,6 +27,7 @@ export const resaveAsImageWithScene = async (
26 27
     fileHandleType,
27 28
     getNonDeletedElements(elements),
28 29
     appState,
30
+    files,
29 31
     {
30 32
       exportBackground,
31 33
       viewBackgroundColor,

+ 23
- 11
src/data/restore.ts View File

@@ -3,7 +3,7 @@ import {
3 3
   ExcalidrawSelectionElement,
4 4
   FontFamilyValues,
5 5
 } from "../element/types";
6
-import { AppState, NormalizedZoomValue } from "../types";
6
+import { AppState, BinaryFiles, NormalizedZoomValue } from "../types";
7 7
 import { ImportedDataState } from "./types";
8 8
 import {
9 9
   getElementMap,
@@ -37,6 +37,7 @@ export const AllowedExcalidrawElementTypes: Record<
37 37
   diamond: true,
38 38
   ellipse: true,
39 39
   line: true,
40
+  image: true,
40 41
   arrow: true,
41 42
   freedraw: true,
42 43
 };
@@ -44,6 +45,7 @@ export const AllowedExcalidrawElementTypes: Record<
44 45
 export type RestoredDataState = {
45 46
   elements: ExcalidrawElement[];
46 47
   appState: RestoredAppState;
48
+  files: BinaryFiles;
47 49
 };
48 50
 
49 51
 const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
@@ -57,16 +59,19 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
57 59
 
58 60
 const restoreElementWithProperties = <
59 61
   T extends ExcalidrawElement,
60
-  K extends keyof Omit<
61
-    Required<T>,
62
-    Exclude<keyof ExcalidrawElement, "type" | "x" | "y">
63
-  >
62
+  K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>
64 63
 >(
65 64
   element: Required<T>,
66
-  extra: Pick<T, K>,
65
+  extra: Pick<
66
+    T,
67
+    // This extra Pick<T, keyof K> ensure no excess properties are passed.
68
+    // @ts-ignore TS complains here but type checks the call sites fine.
69
+    keyof K
70
+  > &
71
+    Partial<Pick<ExcalidrawElement, "type" | "x" | "y">>,
67 72
 ): T => {
68 73
   const base: Pick<T, keyof ExcalidrawElement> = {
69
-    type: (extra as Partial<T>).type || element.type,
74
+    type: extra.type || element.type,
70 75
     // all elements must have version > 0 so getSceneVersion() will pick up
71 76
     // newly added elements
72 77
     version: element.version || 1,
@@ -79,8 +84,8 @@ const restoreElementWithProperties = <
79 84
     roughness: element.roughness ?? 1,
80 85
     opacity: element.opacity == null ? 100 : element.opacity,
81 86
     angle: element.angle || 0,
82
-    x: (extra as Partial<T>).x ?? element.x ?? 0,
83
-    y: (extra as Partial<T>).y ?? element.y ?? 0,
87
+    x: extra.x ?? element.x ?? 0,
88
+    y: extra.y ?? element.y ?? 0,
84 89
     strokeColor: element.strokeColor,
85 90
     backgroundColor: element.backgroundColor,
86 91
     width: element.width || 0,
@@ -102,7 +107,7 @@ const restoreElementWithProperties = <
102 107
 
103 108
 const restoreElement = (
104 109
   element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
105
-): typeof element => {
110
+): typeof element | null => {
106 111
   switch (element.type) {
107 112
     case "text":
108 113
       let fontSize = element.fontSize;
@@ -131,6 +136,12 @@ const restoreElement = (
131 136
         pressures: element.pressures,
132 137
       });
133 138
     }
139
+    case "image":
140
+      return restoreElementWithProperties(element, {
141
+        status: element.status || "pending",
142
+        fileId: element.fileId,
143
+        scale: element.scale || [1, 1],
144
+      });
134 145
     case "line":
135 146
     // @ts-ignore LEGACY type
136 147
     // eslint-disable-next-line no-fallthrough
@@ -194,7 +205,7 @@ export const restoreElements = (
194 205
     // filtering out selection, which is legacy, no longer kept in elements,
195 206
     // and causing issues if retained
196 207
     if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
197
-      let migratedElement: ExcalidrawElement = restoreElement(element);
208
+      let migratedElement: ExcalidrawElement | null = restoreElement(element);
198 209
       if (migratedElement) {
199 210
         const localElement = localElementsMap?.[element.id];
200 211
         if (localElement && localElement.version > migratedElement.version) {
@@ -260,5 +271,6 @@ export const restore = (
260 271
   return {
261 272
     elements: restoreElements(data?.elements, localElements),
262 273
     appState: restoreAppState(data?.appState, localAppState || null),
274
+    files: data?.files || {},
263 275
   };
264 276
 };

+ 3
- 1
src/data/types.ts View File

@@ -1,5 +1,5 @@
1 1
 import { ExcalidrawElement } from "../element/types";
2
-import { AppState, LibraryItems } from "../types";
2
+import { AppState, BinaryFiles, LibraryItems } from "../types";
3 3
 import type { cleanAppStateForExport } from "../appState";
4 4
 
5 5
 export interface ExportedDataState {
@@ -8,6 +8,7 @@ export interface ExportedDataState {
8 8
   source: string;
9 9
   elements: readonly ExcalidrawElement[];
10 10
   appState: ReturnType<typeof cleanAppStateForExport>;
11
+  files: BinaryFiles | undefined;
11 12
 }
12 13
 
13 14
 export interface ImportedDataState {
@@ -18,6 +19,7 @@ export interface ImportedDataState {
18 19
   appState?: Readonly<Partial<AppState>> | null;
19 20
   scrollToContent?: boolean;
20 21
   libraryItems?: LibraryItems;
22
+  files?: BinaryFiles;
21 23
 }
22 24
 
23 25
 export interface ExportedLibraryData {

+ 13
- 3
src/element/collision.ts View File

@@ -23,6 +23,7 @@ import {
23 23
   ExcalidrawEllipseElement,
24 24
   NonDeleted,
25 25
   ExcalidrawFreeDrawElement,
26
+  ExcalidrawImageElement,
26 27
 } from "./types";
27 28
 
28 29
 import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
@@ -30,6 +31,7 @@ import { Point } from "../types";
30 31
 import { Drawable } from "roughjs/bin/core";
31 32
 import { AppState } from "../types";
32 33
 import { getShapeForElement } from "../renderer/renderElement";
34
+import { isImageElement } from "./typeChecks";
33 35
 
34 36
 const isElementDraggableFromInside = (
35 37
   element: NonDeletedExcalidrawElement,
@@ -47,8 +49,7 @@ const isElementDraggableFromInside = (
47 49
   if (element.type === "line") {
48 50
     return isDraggableFromInside && isPathALoop(element.points);
49 51
   }
50
-
51
-  return isDraggableFromInside;
52
+  return isDraggableFromInside || isImageElement(element);
52 53
 };
53 54
 
54 55
 export const hitTest = (
@@ -161,6 +162,7 @@ type HitTestArgs = {
161 162
 const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
162 163
   switch (args.element.type) {
163 164
     case "rectangle":
165
+    case "image":
164 166
     case "text":
165 167
     case "diamond":
166 168
     case "ellipse":
@@ -195,6 +197,7 @@ export const distanceToBindableElement = (
195 197
 ): number => {
196 198
   switch (element.type) {
197 199
     case "rectangle":
200
+    case "image":
198 201
     case "text":
199 202
       return distanceToRectangle(element, point);
200 203
     case "diamond":
@@ -224,7 +227,8 @@ const distanceToRectangle = (
224 227
   element:
225 228
     | ExcalidrawRectangleElement
226 229
     | ExcalidrawTextElement
227
-    | ExcalidrawFreeDrawElement,
230
+    | ExcalidrawFreeDrawElement
231
+    | ExcalidrawImageElement,
228 232
   point: Point,
229 233
 ): number => {
230 234
   const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
@@ -486,6 +490,7 @@ export const determineFocusDistance = (
486 490
   const nabs = Math.abs(n);
487 491
   switch (element.type) {
488 492
     case "rectangle":
493
+    case "image":
489 494
     case "text":
490 495
       return c / (hwidth * (nabs + q * mabs));
491 496
     case "diamond":
@@ -516,6 +521,7 @@ export const determineFocusPoint = (
516 521
   let point;
517 522
   switch (element.type) {
518 523
     case "rectangle":
524
+    case "image":
519 525
     case "text":
520 526
     case "diamond":
521 527
       point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
@@ -565,6 +571,7 @@ const getSortedElementLineIntersections = (
565 571
   let intersections: GA.Point[];
566 572
   switch (element.type) {
567 573
     case "rectangle":
574
+    case "image":
568 575
     case "text":
569 576
     case "diamond":
570 577
       const corners = getCorners(element);
@@ -598,6 +605,7 @@ const getSortedElementLineIntersections = (
598 605
 const getCorners = (
599 606
   element:
600 607
     | ExcalidrawRectangleElement
608
+    | ExcalidrawImageElement
601 609
     | ExcalidrawDiamondElement
602 610
     | ExcalidrawTextElement,
603 611
   scale: number = 1,
@@ -606,6 +614,7 @@ const getCorners = (
606 614
   const hy = (scale * element.height) / 2;
607 615
   switch (element.type) {
608 616
     case "rectangle":
617
+    case "image":
609 618
     case "text":
610 619
       return [
611 620
         GA.point(hx, hy),
@@ -747,6 +756,7 @@ export const findFocusPointForEllipse = (
747 756
 export const findFocusPointForRectangulars = (
748 757
   element:
749 758
     | ExcalidrawRectangleElement
759
+    | ExcalidrawImageElement
750 760
     | ExcalidrawDiamondElement
751 761
     | ExcalidrawTextElement,
752 762
   // Between -1 and 1 for how far away should the focus point be relative

+ 18
- 11
src/element/dragElements.ts View File

@@ -62,25 +62,32 @@ export const dragNewElement = (
62 62
   y: number,
63 63
   width: number,
64 64
   height: number,
65
-  isResizeWithSidesSameLength: boolean,
66
-  isResizeCenterPoint: boolean,
65
+  shouldMaintainAspectRatio: boolean,
66
+  shouldResizeFromCenter: boolean,
67
+  /** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
68
+      true */
69
+  widthAspectRatio?: number | null,
67 70
 ) => {
68
-  if (isResizeWithSidesSameLength) {
69
-    ({ width, height } = getPerfectElementSize(
70
-      elementType,
71
-      width,
72
-      y < originY ? -height : height,
73
-    ));
71
+  if (shouldMaintainAspectRatio) {
72
+    if (widthAspectRatio) {
73
+      height = width / widthAspectRatio;
74
+    } else {
75
+      ({ width, height } = getPerfectElementSize(
76
+        elementType,
77
+        width,
78
+        y < originY ? -height : height,
79
+      ));
74 80
 
75
-    if (height < 0) {
76
-      height = -height;
81
+      if (height < 0) {
82
+        height = -height;
83
+      }
77 84
     }
78 85
   }
79 86
 
80 87
   let newX = x < originX ? originX - width : originX;
81 88
   let newY = y < originY ? originY - height : originY;
82 89
 
83
-  if (isResizeCenterPoint) {
90
+  if (shouldResizeFromCenter) {
84 91
     width += width;
85 92
     height += height;
86 93
     newX = originX - width / 2;

+ 111
- 0
src/element/image.ts View File

@@ -0,0 +1,111 @@
1
+// -----------------------------------------------------------------------------
2
+// ExcalidrawImageElement & related helpers
3
+// -----------------------------------------------------------------------------
4
+
5
+import { MIME_TYPES, SVG_NS } from "../constants";
6
+import { t } from "../i18n";
7
+import { AppClassProperties, DataURL, BinaryFiles } from "../types";
8
+import { isInitializedImageElement } from "./typeChecks";
9
+import {
10
+  ExcalidrawElement,
11
+  FileId,
12
+  InitializedExcalidrawImageElement,
13
+} from "./types";
14
+
15
+export const loadHTMLImageElement = (dataURL: DataURL) => {
16
+  return new Promise<HTMLImageElement>((resolve, reject) => {
17
+    const image = new Image();
18
+    image.onload = () => {
19
+      resolve(image);
20
+    };
21
+    image.onerror = (error) => {
22
+      reject(error);
23
+    };
24
+    image.src = dataURL;
25
+  });
26
+};
27
+
28
+/** NOTE: updates cache even if already populated with given image. Thus,
29
+ * you should filter out the images upstream if you want to optimize this. */
30
+export const updateImageCache = async ({
31
+  fileIds,
32
+  files,
33
+  imageCache,
34
+}: {
35
+  fileIds: FileId[];
36
+  files: BinaryFiles;
37
+  imageCache: AppClassProperties["imageCache"];
38
+}) => {
39
+  const updatedFiles = new Map<FileId, true>();
40
+  const erroredFiles = new Map<FileId, true>();
41
+
42
+  await Promise.all(
43
+    fileIds.reduce((promises, fileId) => {
44
+      const fileData = files[fileId as string];
45
+      if (fileData && !updatedFiles.has(fileId)) {
46
+        updatedFiles.set(fileId, true);
47
+        return promises.concat(
48
+          (async () => {
49
+            try {
50
+              if (fileData.mimeType === MIME_TYPES.binary) {
51
+                throw new Error("Only images can be added to ImageCache");
52
+              }
53
+
54
+              const imagePromise = loadHTMLImageElement(fileData.dataURL);
55
+              const data = {
56
+                image: imagePromise,
57
+                mimeType: fileData.mimeType,
58
+              } as const;
59
+              // store the promise immediately to indicate there's an in-progress
60
+              // initialization
61
+              imageCache.set(fileId, data);
62
+
63
+              const image = await imagePromise;
64
+
65
+              imageCache.set(fileId, { ...data, image });
66
+            } catch (error) {
67
+              erroredFiles.set(fileId, true);
68
+            }
69
+          })(),
70
+        );
71
+      }
72
+      return promises;
73
+    }, [] as Promise<any>[]),
74
+  );
75
+
76
+  return {
77
+    imageCache,
78
+    /** includes errored files because they cache was updated nonetheless */
79
+    updatedFiles,
80
+    /** files that failed when creating HTMLImageElement */
81
+    erroredFiles,
82
+  };
83
+};
84
+
85
+export const getInitializedImageElements = (
86
+  elements: readonly ExcalidrawElement[],
87
+) =>
88
+  elements.filter((element) =>
89
+    isInitializedImageElement(element),
90
+  ) as InitializedExcalidrawImageElement[];
91
+
92
+export const isHTMLSVGElement = (node: Node | null): node is SVGElement => {
93
+  // lower-casing due to XML/HTML convention differences
94
+  // https://johnresig.com/blog/nodename-case-sensitivity
95
+  return node?.nodeName.toLowerCase() === "svg";
96
+};
97
+
98
+export const normalizeSVG = async (SVGString: string) => {
99
+  const doc = new DOMParser().parseFromString(SVGString, MIME_TYPES.svg);
100
+  const svg = doc.querySelector("svg");
101
+  const errorNode = doc.querySelector("parsererror");
102
+  if (errorNode || !isHTMLSVGElement(svg)) {
103
+    throw new Error(t("errors.invalidSVGString"));
104
+  } else {
105
+    if (!svg.hasAttribute("xmlns")) {
106
+      svg.setAttribute("xmlns", SVG_NS);
107
+    }
108
+
109
+    return svg.outerHTML;
110
+  }
111
+};

+ 5
- 0
src/element/index.ts View File

@@ -11,6 +11,7 @@ export {
11 11
   newTextElement,
12 12
   updateTextElement,
13 13
   newLinearElement,
14
+  newImageElement,
14 15
   duplicateElement,
15 16
 } from "./newElement";
16 17
 export {
@@ -93,6 +94,10 @@ const _clearElements = (
93 94
       : element,
94 95
   );
95 96
 
97
+export const clearElementsForDatabase = (
98
+  elements: readonly ExcalidrawElement[],
99
+) => _clearElements(elements);
100
+
96 101
 export const clearElementsForExport = (
97 102
   elements: readonly ExcalidrawElement[],
98 103
 ) => _clearElements(elements);

+ 26
- 10
src/element/mutateElement.ts View File

@@ -17,12 +17,13 @@ type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
17 17
 export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
18 18
   element: TElement,
19 19
   updates: ElementUpdate<TElement>,
20
-) => {
20
+  informMutation = true,
21
+): TElement => {
21 22
   let didChange = false;
22 23
 
23 24
   // casting to any because can't use `in` operator
24 25
   // (see https://github.com/microsoft/TypeScript/issues/21732)
25
-  const { points } = updates as any;
26
+  const { points, fileId } = updates as any;
26 27
 
27 28
   if (typeof points !== "undefined") {
28 29
     updates = { ...getSizeFromPoints(points), ...updates };
@@ -33,13 +34,23 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
33 34
     if (typeof value !== "undefined") {
34 35
       if (
35 36
         (element as any)[key] === value &&
36
-        // if object, always update in case its deep prop was mutated
37
-        (typeof value !== "object" || value === null || key === "groupIds")
37
+        // if object, always update because its attrs could have changed
38
+        // (except for specific keys we handle below)
39
+        (typeof value !== "object" ||
40
+          value === null ||
41
+          key === "groupIds" ||
42
+          key === "scale")
38 43
       ) {
39 44
         continue;
40 45
       }
41 46
 
42
-      if (key === "points") {
47
+      if (key === "scale") {
48
+        const prevScale = (element as any)[key];
49
+        const nextScale = value;
50
+        if (prevScale[0] === nextScale[0] && prevScale[1] === nextScale[1]) {
51
+          continue;
52
+        }
53
+      } else if (key === "points") {
43 54
         const prevPoints = (element as any)[key];
44 55
         const nextPoints = value;
45 56
         if (prevPoints.length === nextPoints.length) {
@@ -66,14 +77,14 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
66 77
       didChange = true;
67 78
     }
68 79
   }
69
-
70 80
   if (!didChange) {
71
-    return;
81
+    return element;
72 82
   }
73 83
 
74 84
   if (
75 85
     typeof updates.height !== "undefined" ||
76 86
     typeof updates.width !== "undefined" ||
87
+    typeof fileId != "undefined" ||
77 88
     typeof points !== "undefined"
78 89
   ) {
79 90
     invalidateShapeForElement(element);
@@ -81,7 +92,12 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
81 92
 
82 93
   element.version++;
83 94
   element.versionNonce = randomInteger();
84
-  Scene.getScene(element)?.informMutation();
95
+
96
+  if (informMutation) {
97
+    Scene.getScene(element)?.informMutation();
98
+  }
99
+
100
+  return element;
85 101
 };
86 102
 
87 103
 export const newElementWith = <TElement extends ExcalidrawElement>(
@@ -94,8 +110,8 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
94 110
     if (typeof value !== "undefined") {
95 111
       if (
96 112
         (element as any)[key] === value &&
97
-        // if object, always update in case its deep prop was mutated
98
-        (typeof value !== "object" || value === null || key === "groupIds")
113
+        // if object, always update because its attrs could have changed
114
+        (typeof value !== "object" || value === null)
99 115
       ) {
100 116
         continue;
101 117
       }

+ 17
- 0
src/element/newElement.ts View File

@@ -1,5 +1,6 @@
1 1
 import {
2 2
   ExcalidrawElement,
3
+  ExcalidrawImageElement,
3 4
   ExcalidrawTextElement,
4 5
   ExcalidrawLinearElement,
5 6
   ExcalidrawGenericElement,
@@ -248,6 +249,22 @@ export const newLinearElement = (
248 249
   };
249 250
 };
250 251
 
252
+export const newImageElement = (
253
+  opts: {
254
+    type: ExcalidrawImageElement["type"];
255
+  } & ElementConstructorOpts,
256
+): NonDeleted<ExcalidrawImageElement> => {
257
+  return {
258
+    ..._newElementBase<ExcalidrawImageElement>("image", opts),
259
+    // in the future we'll support changing stroke color for some SVG elements,
260
+    // and `transparent` will likely mean "use original colors of the image"
261
+    strokeColor: "transparent",
262
+    status: "pending",
263
+    fileId: null,
264
+    scale: [1, 1],
265
+  };
266
+};
267
+
251 268
 // Simplified deep clone for the purpose of cloning ExcalidrawElement only
252 269
 // (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
253 270
 //

+ 40
- 28
src/element/resizeElements.ts View File

@@ -47,9 +47,9 @@ export const transformElements = (
47 47
   transformHandleType: MaybeTransformHandleType,
48 48
   selectedElements: readonly NonDeletedExcalidrawElement[],
49 49
   resizeArrowDirection: "origin" | "end",
50
-  isRotateWithDiscreteAngle: boolean,
51
-  isResizeCenterPoint: boolean,
52
-  shouldKeepSidesRatio: boolean,
50
+  shouldRotateWithDiscreteAngle: boolean,
51
+  shouldResizeFromCenter: boolean,
52
+  shouldMaintainAspectRatio: boolean,
53 53
   pointerX: number,
54 54
   pointerY: number,
55 55
   centerX: number,
@@ -62,7 +62,7 @@ export const transformElements = (
62 62
         element,
63 63
         pointerX,
64 64
         pointerY,
65
-        isRotateWithDiscreteAngle,
65
+        shouldRotateWithDiscreteAngle,
66 66
       );
67 67
       updateBoundElements(element);
68 68
     } else if (
@@ -76,7 +76,7 @@ export const transformElements = (
76 76
       reshapeSingleTwoPointElement(
77 77
         element,
78 78
         resizeArrowDirection,
79
-        isRotateWithDiscreteAngle,
79
+        shouldRotateWithDiscreteAngle,
80 80
         pointerX,
81 81
         pointerY,
82 82
       );
@@ -90,7 +90,7 @@ export const transformElements = (
90 90
       resizeSingleTextElement(
91 91
         element,
92 92
         transformHandleType,
93
-        isResizeCenterPoint,
93
+        shouldResizeFromCenter,
94 94
         pointerX,
95 95
         pointerY,
96 96
       );
@@ -98,10 +98,10 @@ export const transformElements = (
98 98
     } else if (transformHandleType) {
99 99
       resizeSingleElement(
100 100
         pointerDownState.originalElements.get(element.id) as typeof element,
101
-        shouldKeepSidesRatio,
101
+        shouldMaintainAspectRatio,
102 102
         element,
103 103
         transformHandleType,
104
-        isResizeCenterPoint,
104
+        shouldResizeFromCenter,
105 105
         pointerX,
106 106
         pointerY,
107 107
       );
@@ -115,7 +115,7 @@ export const transformElements = (
115 115
         selectedElements,
116 116
         pointerX,
117 117
         pointerY,
118
-        isRotateWithDiscreteAngle,
118
+        shouldRotateWithDiscreteAngle,
119 119
         centerX,
120 120
         centerY,
121 121
       );
@@ -142,13 +142,13 @@ const rotateSingleElement = (
142 142
   element: NonDeletedExcalidrawElement,
143 143
   pointerX: number,
144 144
   pointerY: number,
145
-  isRotateWithDiscreteAngle: boolean,
145
+  shouldRotateWithDiscreteAngle: boolean,
146 146
 ) => {
147 147
   const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
148 148
   const cx = (x1 + x2) / 2;
149 149
   const cy = (y1 + y2) / 2;
150 150
   let angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
151
-  if (isRotateWithDiscreteAngle) {
151
+  if (shouldRotateWithDiscreteAngle) {
152 152
     angle += SHIFT_LOCKING_ANGLE / 2;
153 153
     angle -= angle % SHIFT_LOCKING_ANGLE;
154 154
   }
@@ -187,7 +187,7 @@ const getPerfectElementSizeWithRotation = (
187 187
 export const reshapeSingleTwoPointElement = (
188 188
   element: NonDeleted<ExcalidrawLinearElement>,
189 189
   resizeArrowDirection: "origin" | "end",
190
-  isRotateWithDiscreteAngle: boolean,
190
+  shouldRotateWithDiscreteAngle: boolean,
191 191
   pointerX: number,
192 192
   pointerY: number,
193 193
 ) => {
@@ -212,7 +212,7 @@ export const reshapeSingleTwoPointElement = (
212 212
           element.x + element.points[1][0] - rotatedX,
213 213
           element.y + element.points[1][1] - rotatedY,
214 214
         ];
215
-  if (isRotateWithDiscreteAngle) {
215
+  if (shouldRotateWithDiscreteAngle) {
216 216
     [width, height] = getPerfectElementSizeWithRotation(
217 217
       element.type,
218 218
       width,
@@ -281,28 +281,28 @@ const measureFontSizeFromWH = (
281 281
 
282 282
 const getSidesForTransformHandle = (
283 283
   transformHandleType: TransformHandleType,
284
-  isResizeFromCenter: boolean,
284
+  shouldResizeFromCenter: boolean,
285 285
 ) => {
286 286
   return {
287 287
     n:
288 288
       /^(n|ne|nw)$/.test(transformHandleType) ||
289
-      (isResizeFromCenter && /^(s|se|sw)$/.test(transformHandleType)),
289
+      (shouldResizeFromCenter && /^(s|se|sw)$/.test(transformHandleType)),
290 290
     s:
291 291
       /^(s|se|sw)$/.test(transformHandleType) ||
292
-      (isResizeFromCenter && /^(n|ne|nw)$/.test(transformHandleType)),
292
+      (shouldResizeFromCenter && /^(n|ne|nw)$/.test(transformHandleType)),
293 293
     w:
294 294
       /^(w|nw|sw)$/.test(transformHandleType) ||
295
-      (isResizeFromCenter && /^(e|ne|se)$/.test(transformHandleType)),
295
+      (shouldResizeFromCenter && /^(e|ne|se)$/.test(transformHandleType)),
296 296
     e:
297 297
       /^(e|ne|se)$/.test(transformHandleType) ||
298
-      (isResizeFromCenter && /^(w|nw|sw)$/.test(transformHandleType)),
298
+      (shouldResizeFromCenter && /^(w|nw|sw)$/.test(transformHandleType)),
299 299
   };
300 300
 };
301 301
 
302 302
 const resizeSingleTextElement = (
303 303
   element: NonDeleted<ExcalidrawTextElement>,
304 304
   transformHandleType: "nw" | "ne" | "sw" | "se",
305
-  isResizeFromCenter: boolean,
305
+  shouldResizeFromCenter: boolean,
306 306
   pointerX: number,
307 307
   pointerY: number,
308 308
 ) => {
@@ -361,7 +361,7 @@ const resizeSingleTextElement = (
361 361
     const deltaX2 = (x2 - nextX2) / 2;
362 362
     const deltaY2 = (y2 - nextY2) / 2;
363 363
     const [nextElementX, nextElementY] = adjustXYWithRotation(
364
-      getSidesForTransformHandle(transformHandleType, isResizeFromCenter),
364
+      getSidesForTransformHandle(transformHandleType, shouldResizeFromCenter),
365 365
       element.x,
366 366
       element.y,
367 367
       element.angle,
@@ -383,10 +383,10 @@ const resizeSingleTextElement = (
383 383
 
384 384
 export const resizeSingleElement = (
385 385
   stateAtResizeStart: NonDeletedExcalidrawElement,
386
-  shouldKeepSidesRatio: boolean,
386
+  shouldMaintainAspectRatio: boolean,
387 387
   element: NonDeletedExcalidrawElement,
388 388
   transformHandleDirection: TransformHandleDirection,
389
-  isResizeFromCenter: boolean,
389
+  shouldResizeFromCenter: boolean,
390 390
   pointerX: number,
391 391
   pointerY: number,
392 392
 ) => {
@@ -444,13 +444,13 @@ export const resizeSingleElement = (
444 444
   let eleNewHeight = element.height * scaleY;
445 445
 
446 446
   // adjust dimensions for resizing from center
447
-  if (isResizeFromCenter) {
447
+  if (shouldResizeFromCenter) {
448 448
     eleNewWidth = 2 * eleNewWidth - eleInitialWidth;
449 449
     eleNewHeight = 2 * eleNewHeight - eleInitialHeight;
450 450
   }
451 451
 
452 452
   // adjust dimensions to keep sides ratio
453
-  if (shouldKeepSidesRatio) {
453
+  if (shouldMaintainAspectRatio) {
454 454
     const widthRatio = Math.abs(eleNewWidth) / eleInitialWidth;
455 455
     const heightRatio = Math.abs(eleNewHeight) / eleInitialHeight;
456 456
     if (transformHandleDirection.length === 1) {
@@ -495,7 +495,7 @@ export const resizeSingleElement = (
495 495
   }
496 496
 
497 497
   // Keeps opposite handle fixed during resize
498
-  if (shouldKeepSidesRatio) {
498
+  if (shouldMaintainAspectRatio) {
499 499
     if (["s", "n"].includes(transformHandleDirection)) {
500 500
       newTopLeft[0] = startCenter[0] - newBoundsWidth / 2;
501 501
     }
@@ -523,7 +523,7 @@ export const resizeSingleElement = (
523 523
     }
524 524
   }
525 525
 
526
-  if (isResizeFromCenter) {
526
+  if (shouldResizeFromCenter) {
527 527
     newTopLeft[0] = startCenter[0] - Math.abs(newBoundsWidth) / 2;
528 528
     newTopLeft[1] = startCenter[1] - Math.abs(newBoundsHeight) / 2;
529 529
   }
@@ -558,6 +558,18 @@ export const resizeSingleElement = (
558 558
     ...rescaledPoints,
559 559
   };
560 560
 
561
+  if ("scale" in element && "scale" in stateAtResizeStart) {
562
+    mutateElement(element, {
563
+      scale: [
564
+        // defaulting because scaleX/Y can be 0/-0
565
+        (Math.sign(scaleX) || stateAtResizeStart.scale[0]) *
566
+          stateAtResizeStart.scale[0],
567
+        (Math.sign(scaleY) || stateAtResizeStart.scale[1]) *
568
+          stateAtResizeStart.scale[1],
569
+      ],
570
+    });
571
+  }
572
+
561 573
   if (
562 574
     resizedElement.width !== 0 &&
563 575
     resizedElement.height !== 0 &&
@@ -692,13 +704,13 @@ const rotateMultipleElements = (
692 704
   elements: readonly NonDeletedExcalidrawElement[],
693 705
   pointerX: number,
694 706
   pointerY: number,
695
-  isRotateWithDiscreteAngle: boolean,
707
+  shouldRotateWithDiscreteAngle: boolean,
696 708
   centerX: number,
697 709
   centerY: number,
698 710
 ) => {
699 711
   let centerAngle =
700 712
     (5 * Math.PI) / 2 + Math.atan2(pointerY - centerY, pointerX - centerX);
701
-  if (isRotateWithDiscreteAngle) {
713
+  if (shouldRotateWithDiscreteAngle) {
702 714
     centerAngle += SHIFT_LOCKING_ANGLE / 2;
703 715
     centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
704 716
   }

+ 14
- 0
src/element/typeChecks.ts View File

@@ -5,6 +5,8 @@ import {
5 5
   ExcalidrawBindableElement,
6 6
   ExcalidrawGenericElement,
7 7
   ExcalidrawFreeDrawElement,
8
+  InitializedExcalidrawImageElement,
9
+  ExcalidrawImageElement,
8 10
 } from "./types";
9 11
 
10 12
 export const isGenericElement = (
@@ -19,6 +21,18 @@ export const isGenericElement = (
19 21
   );
20 22
 };
21 23
 
24
+export const isInitializedImageElement = (
25
+  element: ExcalidrawElement | null,
26
+): element is InitializedExcalidrawImageElement => {
27
+  return !!element && element.type === "image" && !!element.fileId;
28
+};
29
+
30
+export const isImageElement = (
31
+  element: ExcalidrawElement | null,
32
+): element is ExcalidrawImageElement => {
33
+  return !!element && element.type === "image";
34
+};
35
+
22 36
 export const isTextElement = (
23 37
   element: ExcalidrawElement | null,
24 38
 ): element is ExcalidrawTextElement => {

+ 22
- 3
src/element/types.ts View File

@@ -63,6 +63,21 @@ export type ExcalidrawEllipseElement = _ExcalidrawElementBase & {
63 63
   type: "ellipse";
64 64
 };
65 65
 
66
+export type ExcalidrawImageElement = _ExcalidrawElementBase &
67
+  Readonly<{
68
+    type: "image";
69
+    fileId: FileId | null;
70
+    /** whether respective file is persisted */
71
+    status: "pending" | "saved" | "error";
72
+    /** X and Y scale factors <-1, 1>, used for image axis flipping */
73
+    scale: [number, number];
74
+  }>;
75
+
76
+export type InitializedExcalidrawImageElement = MarkNonNullable<
77
+  ExcalidrawImageElement,
78
+  "fileId"
79
+>;
80
+
66 81
 /**
67 82
  * These are elements that don't have any additional properties.
68 83
  */
@@ -81,10 +96,11 @@ export type ExcalidrawElement =
81 96
   | ExcalidrawGenericElement
82 97
   | ExcalidrawTextElement
83 98
   | ExcalidrawLinearElement
84
-  | ExcalidrawFreeDrawElement;
99
+  | ExcalidrawFreeDrawElement
100
+  | ExcalidrawImageElement;
85 101
 
86 102
 export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
87
-  isDeleted: false;
103
+  isDeleted: boolean;
88 104
 };
89 105
 
90 106
 export type NonDeletedExcalidrawElement = NonDeleted<ExcalidrawElement>;
@@ -104,7 +120,8 @@ export type ExcalidrawBindableElement =
104 120
   | ExcalidrawRectangleElement
105 121
   | ExcalidrawDiamondElement
106 122
   | ExcalidrawEllipseElement
107
-  | ExcalidrawTextElement;
123
+  | ExcalidrawTextElement
124
+  | ExcalidrawImageElement;
108 125
 
109 126
 export type PointBinding = {
110 127
   elementId: ExcalidrawBindableElement["id"];
@@ -133,3 +150,5 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
133 150
     simulatePressure: boolean;
134 151
     lastCommittedPoint: Point | null;
135 152
   }>;
153
+
154
+export type FileId = string & { _brand: "FileId" };

+ 11
- 0
src/excalidraw-app/app_constants.ts View File

@@ -1,8 +1,14 @@
1 1
 // time constants (ms)
2 2
 export const SAVE_TO_LOCAL_STORAGE_TIMEOUT = 300;
3 3
 export const INITIAL_SCENE_UPDATE_TIMEOUT = 5000;
4
+export const FILE_UPLOAD_TIMEOUT = 300;
5
+export const LOAD_IMAGES_TIMEOUT = 500;
4 6
 export const SYNC_FULL_SCENE_INTERVAL_MS = 20000;
5 7
 
8
+export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
9
+// 1 year (https://stackoverflow.com/a/25201898/927631)
10
+export const FILE_CACHE_MAX_AGE_SEC = 31536000;
11
+
6 12
 export const BROADCAST = {
7 13
   SERVER_VOLATILE: "server-volatile-broadcast",
8 14
   SERVER: "server-broadcast",
@@ -12,3 +18,8 @@ export enum SCENE {
12 18
   INIT = "SCENE_INIT",
13 19
   UPDATE = "SCENE_UPDATE",
14 20
 }
21
+
22
+export const FIREBASE_STORAGE_PREFIXES = {
23
+  shareLinkFiles: `/files/shareLinks`,
24
+  collabFiles: `/files/rooms`,
25
+};

+ 128
- 9
src/excalidraw-app/collab/CollabWrapper.tsx View File

@@ -4,15 +4,25 @@ import { ExcalidrawImperativeAPI } from "../../types";
4 4
 import { ErrorDialog } from "../../components/ErrorDialog";
5 5
 import { APP_NAME, ENV, EVENT } from "../../constants";
6 6
 import { ImportedDataState } from "../../data/types";
7
-import { ExcalidrawElement } from "../../element/types";
7
+import {
8
+  ExcalidrawElement,
9
+  InitializedExcalidrawImageElement,
10
+} from "../../element/types";
8 11
 import {
9 12
   getElementMap,
10 13
   getSceneVersion,
11 14
 } from "../../packages/excalidraw/index";
12 15
 import { Collaborator, Gesture } from "../../types";
13
-import { resolvablePromise, withBatchedUpdates } from "../../utils";
14 16
 import {
17
+  preventUnload,
18
+  resolvablePromise,
19
+  withBatchedUpdates,
20
+} from "../../utils";
21
+import {
22
+  FILE_UPLOAD_MAX_BYTES,
23
+  FIREBASE_STORAGE_PREFIXES,
15 24
   INITIAL_SCENE_UPDATE_TIMEOUT,
25
+  LOAD_IMAGES_TIMEOUT,
16 26
   SCENE,
17 27
   SYNC_FULL_SCENE_INTERVAL_MS,
18 28
 } from "../app_constants";
@@ -25,7 +35,9 @@ import {
25 35
 } from "../data";
26 36
 import {
27 37
   isSavedToFirebase,
38
+  loadFilesFromFirebase,
28 39
   loadFromFirebase,
40
+  saveFilesToFirebase,
29 41
   saveToFirebase,
30 42
 } from "../data/firebase";
31 43
 import {
@@ -41,6 +53,17 @@ import { UserIdleState } from "../../types";
41 53
 import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
42 54
 import { trackEvent } from "../../analytics";
43 55
 import { isInvisiblySmallElement } from "../../element";
56
+import {
57
+  encodeFilesForUpload,
58
+  FileManager,
59
+  updateStaleImageStatuses,
60
+} from "../data/FileManager";
61
+import { AbortError } from "../../errors";
62
+import {
63
+  isImageElement,
64
+  isInitializedImageElement,
65
+} from "../../element/typeChecks";
66
+import { mutateElement } from "../../element/mutateElement";
44 67
 
45 68
 interface CollabState {
46 69
   modalIsShown: boolean;
@@ -61,6 +84,7 @@ export interface CollabAPI {
61 84
   initializeSocketClient: CollabInstance["initializeSocketClient"];
62 85
   onCollabButtonClick: CollabInstance["onCollabButtonClick"];
63 86
   broadcastElements: CollabInstance["broadcastElements"];
87
+  fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
64 88
 }
65 89
 
66 90
 type ReconciledElements = readonly ExcalidrawElement[] & {
@@ -69,6 +93,7 @@ type ReconciledElements = readonly ExcalidrawElement[] & {
69 93
 
70 94
 interface Props {
71 95
   excalidrawAPI: ExcalidrawImperativeAPI;
96
+  onRoomClose?: () => void;
72 97
 }
73 98
 
74 99
 const {
@@ -81,12 +106,13 @@ export { CollabContext, CollabContextConsumer };
81 106
 
82 107
 class CollabWrapper extends PureComponent<Props, CollabState> {
83 108
   portal: Portal;
109
+  fileManager: FileManager;
84 110
   excalidrawAPI: Props["excalidrawAPI"];
85 111
   isCollaborating: boolean = false;
86 112
   activeIntervalId: number | null;
87 113
   idleTimeoutId: number | null;
88 114
 
89
-  private socketInitializationTimer?: NodeJS.Timeout;
115
+  private socketInitializationTimer?: number;
90 116
   private lastBroadcastedOrReceivedSceneVersion: number = -1;
91 117
   private collaborators = new Map<string, Collaborator>();
92 118
 
@@ -100,6 +126,31 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
100 126
       activeRoomLink: "",
101 127
     };
102 128
     this.portal = new Portal(this);
129
+    this.fileManager = new FileManager({
130
+      getFiles: async (fileIds) => {
131
+        const { roomId, roomKey } = this.portal;
132
+        if (!roomId || !roomKey) {
133
+          throw new AbortError();
134
+        }
135
+
136
+        return loadFilesFromFirebase(`files/rooms/${roomId}`, roomKey, fileIds);
137
+      },
138
+      saveFiles: async ({ addedFiles }) => {
139
+        const { roomId, roomKey } = this.portal;
140
+        if (!roomId || !roomKey) {
141
+          throw new AbortError();
142
+        }
143
+
144
+        return saveFilesToFirebase({
145
+          prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
146
+          files: await encodeFilesForUpload({
147
+            files: addedFiles,
148
+            encryptionKey: roomKey,
149
+            maxBytes: FILE_UPLOAD_MAX_BYTES,
150
+          }),
151
+        });
152
+      },
153
+    });
103 154
     this.excalidrawAPI = props.excalidrawAPI;
104 155
     this.activeIntervalId = null;
105 156
     this.idleTimeoutId = null;
@@ -152,15 +203,14 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
152 203
 
153 204
     if (
154 205
       this.isCollaborating &&
155
-      !isSavedToFirebase(this.portal, syncableElements)
206
+      (this.fileManager.shouldPreventUnload(syncableElements) ||
207
+        !isSavedToFirebase(this.portal, syncableElements))
156 208
     ) {
157 209
       // this won't run in time if user decides to leave the site, but
158 210
       //  the purpose is to run in immediately after user decides to stay
159 211
       this.saveCollabRoomToFirebase(syncableElements);
160 212
 
161
-      event.preventDefault();
162
-      // NOTE: modern browsers no longer allow showing a custom message here
163
-      event.returnValue = "";
213
+      preventUnload(event);
164 214
     }
165 215
 
166 216
     if (this.isCollaborating || this.portal.roomId) {
@@ -199,6 +249,22 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
199 249
       window.history.pushState({}, APP_NAME, window.location.origin);
200 250
       this.destroySocketClient();
201 251
       trackEvent("share", "room closed");
252
+
253
+      this.props.onRoomClose?.();
254
+
255
+      const elements = this.excalidrawAPI
256
+        .getSceneElementsIncludingDeleted()
257
+        .map((element) => {
258
+          if (isImageElement(element) && element.status === "saved") {
259
+            return mutateElement(element, { status: "pending" }, false);
260
+          }
261
+          return element;
262
+        });
263
+
264
+      this.excalidrawAPI.updateScene({
265
+        elements,
266
+        commitToHistory: false,
267
+      });
202 268
     }
203 269
   };
204 270
 
@@ -213,7 +279,26 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
213 279
       });
214 280
       this.isCollaborating = false;
215 281
     }
282
+    this.lastBroadcastedOrReceivedSceneVersion = -1;
216 283
     this.portal.close();
284
+    this.fileManager.reset();
285
+  };
286
+
287
+  private fetchImageFilesFromFirebase = async (scene: {
288
+    elements: readonly ExcalidrawElement[];
289
+  }) => {
290
+    const unfetchedImages = scene.elements
291
+      .filter((element) => {
292
+        return (
293
+          isInitializedImageElement(element) &&
294
+          !this.fileManager.isFileHandled(element.fileId) &&
295
+          !element.isDeleted &&
296
+          element.status === "saved"
297
+        );
298
+      })
299
+      .map((element) => (element as InitializedExcalidrawImageElement).fileId);
300
+
301
+    return await this.fileManager.getFiles(unfetchedImages);
217 302
   };
218 303
 
219 304
   private initializeSocketClient = async (
@@ -267,7 +352,16 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
267 352
         console.error(error);
268 353
       }
269 354
     } else {
270
-      const elements = this.excalidrawAPI.getSceneElements();
355
+      const elements = this.excalidrawAPI.getSceneElements().map((element) => {
356
+        if (isImageElement(element) && element.status === "saved") {
357
+          return mutateElement(
358
+            element,
359
+            { status: "pending" },
360
+            /* informMutation */ false,
361
+          );
362
+        }
363
+        return element;
364
+      });
271 365
       // remove deleted elements from elements array & history to ensure we don't
272 366
       // expose potentially sensitive user data in case user manually deletes
273 367
       // existing elements (or clears scene), which would otherwise be persisted
@@ -277,11 +371,16 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
277 371
         elements,
278 372
         commitToHistory: true,
279 373
       });
374
+
375
+      this.broadcastElements(elements);
376
+
377
+      const syncableElements = this.getSyncableElements(elements);
378
+      this.saveCollabRoomToFirebase(syncableElements);
280 379
     }
281 380
 
282 381
     // fallback in case you're not alone in the room but still don't receive
283 382
     // initial SCENE_UPDATE message
284
-    this.socketInitializationTimer = setTimeout(() => {
383
+    this.socketInitializationTimer = window.setTimeout(() => {
285 384
       this.initializeSocket();
286 385
       scenePromise.resolve(null);
287 386
     }, INITIAL_SCENE_UPDATE_TIMEOUT);
@@ -446,6 +545,23 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
446 545
     return newElements as ReconciledElements;
447 546
   };
448 547
 
548
+  private loadImageFiles = throttle(async () => {
549
+    const {
550
+      loadedFiles,
551
+      erroredFiles,
552
+    } = await this.fetchImageFilesFromFirebase({
553
+      elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
554
+    });
555
+
556
+    this.excalidrawAPI.addFiles(loadedFiles);
557
+
558
+    updateStaleImageStatuses({
559
+      excalidrawAPI: this.excalidrawAPI,
560
+      erroredFiles,
561
+      elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
562
+    });
563
+  }, LOAD_IMAGES_TIMEOUT);
564
+
449 565
   private handleRemoteSceneUpdate = (
450 566
     elements: ReconciledElements,
451 567
     { init = false }: { init?: boolean } = {},
@@ -460,6 +576,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
460 576
     // undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
461 577
     // right now we think this is the right tradeoff.
462 578
     this.excalidrawAPI.history.clear();
579
+
580
+    this.loadImageFiles();
463 581
   };
464 582
 
465 583
   private onPointerMove = () => {
@@ -622,6 +740,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
622 740
     this.contextValue.initializeSocketClient = this.initializeSocketClient;
623 741
     this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
624 742
     this.contextValue.broadcastElements = this.broadcastElements;
743
+    this.contextValue.fetchImageFilesFromFirebase = this.fetchImageFilesFromFirebase;
625 744
     return this.contextValue;
626 745
   };
627 746
 

+ 38
- 1
src/excalidraw-app/collab/Portal.tsx View File

@@ -7,9 +7,11 @@ import {
7 7
 import CollabWrapper from "./CollabWrapper";
8 8
 
9 9
 import { ExcalidrawElement } from "../../element/types";
10
-import { BROADCAST, SCENE } from "../app_constants";
10
+import { BROADCAST, FILE_UPLOAD_TIMEOUT, SCENE } from "../app_constants";
11 11
 import { UserIdleState } from "../../types";
12 12
 import { trackEvent } from "../../analytics";
13
+import { throttle } from "lodash";
14
+import { mutateElement } from "../../element/mutateElement";
13 15
 
14 16
 class Portal {
15 17
   collab: CollabWrapper;
@@ -87,6 +89,39 @@ class Portal {
87 89
     }
88 90
   }
89 91
 
92
+  queueFileUpload = throttle(async () => {
93
+    try {
94
+      await this.collab.fileManager.saveFiles({
95
+        elements: this.collab.excalidrawAPI.getSceneElementsIncludingDeleted(),
96
+        files: this.collab.excalidrawAPI.getFiles(),
97
+      });
98
+    } catch (error) {
99
+      this.collab.excalidrawAPI.updateScene({
100
+        appState: {
101
+          errorMessage: error.message,
102
+        },
103
+      });
104
+    }
105
+
106
+    this.collab.excalidrawAPI.updateScene({
107
+      elements: this.collab.excalidrawAPI
108
+        .getSceneElementsIncludingDeleted()
109
+        .map((element) => {
110
+          if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
111
+            // this will signal collaborators to pull image data from server
112
+            // (using mutation instead of newElementWith otherwise it'd break
113
+            // in-progress dragging)
114
+            return mutateElement(
115
+              element,
116
+              { status: "saved" },
117
+              /* informMutation */ false,
118
+            );
119
+          }
120
+          return element;
121
+        }),
122
+    });
123
+  }, FILE_UPLOAD_TIMEOUT);
124
+
90 125
   broadcastScene = async (
91 126
     sceneType: SCENE.INIT | SCENE.UPDATE,
92 127
     syncableElements: ExcalidrawElement[],
@@ -126,6 +161,8 @@ class Portal {
126 161
       data as SocketUpdateData,
127 162
     );
128 163
 
164
+    this.queueFileUpload();
165
+
129 166
     if (syncAll && this.collab.isCollaborating) {
130 167
       await Promise.all([
131 168
         broadcastPromise,

+ 45
- 33
src/excalidraw-app/components/ExportToExcalidrawPlus.tsx View File

@@ -2,69 +2,81 @@ import React from "react";
2 2
 import { Card } from "../../components/Card";
3 3
 import { ToolButton } from "../../components/ToolButton";
4 4
 import { serializeAsJSON } from "../../data/json";
5
-import { getImportedKey, createIV, generateEncryptionKey } from "../data";
6
-import { loadFirebaseStorage } from "../data/firebase";
7
-import { NonDeletedExcalidrawElement } from "../../element/types";
8
-import { AppState } from "../../types";
5
+import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
6
+import { FileId, NonDeletedExcalidrawElement } from "../../element/types";
7
+import { AppState, BinaryFileData, BinaryFiles } from "../../types";
9 8
 import { nanoid } from "nanoid";
10 9
 import { t } from "../../i18n";
11 10
 import { excalidrawPlusIcon } from "./icons";
12
-
13
-const encryptData = async (
14
-  key: string,
15
-  json: string,
16
-): Promise<{ blob: Blob; iv: Uint8Array }> => {
17
-  const importedKey = await getImportedKey(key, "encrypt");
18
-  const iv = createIV();
19
-  const encoded = new TextEncoder().encode(json);
20
-  const ciphertext = await window.crypto.subtle.encrypt(
21
-    {
22
-      name: "AES-GCM",
23
-      iv,
24
-    },
25
-    importedKey,
26
-    encoded,
27
-  );
28
-
29
-  return { blob: new Blob([new Uint8Array(ciphertext)]), iv };
30
-};
11
+import { encryptData, generateEncryptionKey } from "../../data/encryption";
12
+import { isInitializedImageElement } from "../../element/typeChecks";
13
+import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
14
+import { encodeFilesForUpload } from "../data/FileManager";
15
+import { MIME_TYPES } from "../../constants";
31 16
 
32 17
 const exportToExcalidrawPlus = async (
33 18
   elements: readonly NonDeletedExcalidrawElement[],
34 19
   appState: AppState,
20
+  files: BinaryFiles,
35 21
 ) => {
36 22
   const firebase = await loadFirebaseStorage();
37 23
 
38 24
   const id = `${nanoid(12)}`;
39 25
 
40
-  const key = (await generateEncryptionKey())!;
26
+  const encryptionKey = (await generateEncryptionKey())!;
41 27
   const encryptedData = await encryptData(
42
-    key,
43
-    serializeAsJSON(elements, appState),
28
+    encryptionKey,
29
+    serializeAsJSON(elements, appState, files, "database"),
44 30
   );
45 31
 
46
-  const blob = new Blob([encryptedData.iv, encryptedData.blob], {
47
-    type: "application/octet-stream",
48
-  });
32
+  const blob = new Blob(
33
+    [encryptedData.iv, new Uint8Array(encryptedData.encryptedBuffer)],
34
+    {
35
+      type: MIME_TYPES.binary,
36
+    },
37
+  );
49 38
 
50 39
   await firebase
51 40
     .storage()
52 41
     .ref(`/migrations/scenes/${id}`)
53 42
     .put(blob, {
54 43
       customMetadata: {
55
-        data: JSON.stringify({ version: 1, name: appState.name }),
44
+        data: JSON.stringify({ version: 2, name: appState.name }),
56 45
         created: Date.now().toString(),
57 46
       },
58 47
     });
59 48
 
60
-  window.open(`https://plus.excalidraw.com/import?excalidraw=${id},${key}`);
49
+  const filesMap = new Map<FileId, BinaryFileData>();
50
+  for (const element of elements) {
51
+    if (isInitializedImageElement(element) && files[element.fileId]) {
52
+      filesMap.set(element.fileId, files[element.fileId]);
53
+    }
54
+  }
55
+
56
+  if (filesMap.size) {
57
+    const filesToUpload = await encodeFilesForUpload({
58
+      files: filesMap,
59
+      encryptionKey,
60
+      maxBytes: FILE_UPLOAD_MAX_BYTES,
61
+    });
62
+
63
+    await saveFilesToFirebase({
64
+      prefix: `/migrations/files/scenes/${id}`,
65
+      files: filesToUpload,
66
+    });
67
+  }
68
+
69
+  window.open(
70
+    `https://plus.excalidraw.com/import?excalidraw=${id},${encryptionKey}`,
71
+  );
61 72
 };
62 73
 
63 74
 export const ExportToExcalidrawPlus: React.FC<{
64 75
   elements: readonly NonDeletedExcalidrawElement[];
65 76
   appState: AppState;
77
+  files: BinaryFiles;
66 78
   onError: (error: Error) => void;
67
-}> = ({ elements, appState, onError }) => {
79
+}> = ({ elements, appState, files, onError }) => {
68 80
   return (
69 81
     <Card color="indigo">
70 82
       <div className="Card-icon">{excalidrawPlusIcon}</div>
@@ -80,7 +92,7 @@ export const ExportToExcalidrawPlus: React.FC<{
80 92
         showAriaLabel={true}
81 93
         onClick={async () => {
82 94
           try {
83
-            await exportToExcalidrawPlus(elements, appState);
95
+            await exportToExcalidrawPlus(elements, appState, files);
84 96
           } catch (error) {
85 97
             console.error(error);
86 98
             onError(new Error(t("exportDialog.excalidrawplus_exportError")));

+ 249
- 0
src/excalidraw-app/data/FileManager.ts View File

@@ -0,0 +1,249 @@
1
+import { compressData } from "../../data/encode";
2
+import { mutateElement } from "../../element/mutateElement";
3
+import { isInitializedImageElement } from "../../element/typeChecks";
4
+import {
5
+  ExcalidrawElement,
6
+  ExcalidrawImageElement,
7
+  FileId,
8
+  InitializedExcalidrawImageElement,
9
+} from "../../element/types";
10
+import { t } from "../../i18n";
11
+import {
12
+  BinaryFileData,
13
+  BinaryFileMetadata,
14
+  ExcalidrawImperativeAPI,
15
+  BinaryFiles,
16
+} from "../../types";
17
+
18
+export class FileManager {
19
+  /** files being fetched */
20
+  private fetchingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
21
+  /** files being saved */
22
+  private savingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
23
+  /* files already saved to persistent storage */
24
+  private savedFiles = new Map<ExcalidrawImageElement["fileId"], true>();
25
+  private erroredFiles = new Map<ExcalidrawImageElement["fileId"], true>();
26
+
27
+  private _getFiles;
28
+  private _saveFiles;
29
+
30
+  constructor({
31
+    getFiles,
32
+    saveFiles,
33
+  }: {
34
+    getFiles: (
35
+      fileIds: FileId[],
36
+    ) => Promise<{
37
+      loadedFiles: BinaryFileData[];
38
+      erroredFiles: Map<FileId, true>;
39
+    }>;
40
+    saveFiles: (data: {
41
+      addedFiles: Map<FileId, BinaryFileData>;
42
+    }) => Promise<{
43
+      savedFiles: Map<FileId, true>;
44
+      erroredFiles: Map<FileId, true>;
45
+    }>;
46
+  }) {
47
+    this._getFiles = getFiles;
48
+    this._saveFiles = saveFiles;
49
+  }
50
+
51
+  /**
52
+   * returns whether file is already saved or being processed
53
+   */
54
+  isFileHandled = (id: FileId) => {
55
+    return (
56
+      this.savedFiles.has(id) ||
57
+      this.fetchingFiles.has(id) ||
58
+      this.savingFiles.has(id) ||
59
+      this.erroredFiles.has(id)
60
+    );
61
+  };
62
+
63
+  isFileSaved = (id: FileId) => {
64
+    return this.savedFiles.has(id);
65
+  };
66
+
67
+  saveFiles = async ({
68
+    elements,
69
+    files,
70
+  }: {
71
+    elements: readonly ExcalidrawElement[];
72
+    files: BinaryFiles;
73
+  }) => {
74
+    const addedFiles: Map<FileId, BinaryFileData> = new Map();
75
+
76
+    for (const element of elements) {
77
+      if (
78
+        isInitializedImageElement(element) &&
79
+        files[element.fileId] &&
80
+        !this.isFileHandled(element.fileId)
81
+      ) {
82
+        addedFiles.set(element.fileId, files[element.fileId]);
83
+        this.savingFiles.set(element.fileId, true);
84
+      }
85
+    }
86
+
87
+    try {
88
+      const { savedFiles, erroredFiles } = await this._saveFiles({
89
+        addedFiles,
90
+      });
91
+
92
+      for (const [fileId] of savedFiles) {
93
+        this.savedFiles.set(fileId, true);
94
+      }
95
+
96
+      return {
97
+        savedFiles,
98
+        erroredFiles,
99
+      };
100
+    } finally {
101
+      for (const [fileId] of addedFiles) {
102
+        this.savingFiles.delete(fileId);
103
+      }
104
+    }
105
+  };
106
+
107
+  getFiles = async (
108
+    ids: FileId[],
109
+  ): Promise<{
110
+    loadedFiles: BinaryFileData[];
111
+    erroredFiles: Map<FileId, true>;
112
+  }> => {
113
+    if (!ids.length) {
114
+      return {
115
+        loadedFiles: [],
116
+        erroredFiles: new Map(),
117
+      };
118
+    }
119
+    for (const id of ids) {
120
+      this.fetchingFiles.set(id, true);
121
+    }
122
+
123
+    try {
124
+      const { loadedFiles, erroredFiles } = await this._getFiles(ids);
125
+
126
+      for (const file of loadedFiles) {
127
+        this.savedFiles.set(file.id, true);
128
+      }
129
+      for (const [fileId] of erroredFiles) {
130
+        this.erroredFiles.set(fileId, true);
131
+      }
132
+
133
+      return { loadedFiles, erroredFiles };
134
+    } finally {
135
+      for (const id of ids) {
136
+        this.fetchingFiles.delete(id);
137
+      }
138
+    }
139
+  };
140
+
141
+  /** a file element prevents unload only if it's being saved regardless of
142
+   *  its `status`. This ensures that elements who for any reason haven't
143
+   *  beed set to `saved` status don't prevent unload in future sessions.
144
+   *  Technically we should prevent unload when the origin client haven't
145
+   *  yet saved the `status` update to storage, but that should be taken care
146
+   *  of during regular beforeUnload unsaved files check.
147
+   */
148
+  shouldPreventUnload = (elements: readonly ExcalidrawElement[]) => {
149
+    return elements.some((element) => {
150
+      return (
151
+        isInitializedImageElement(element) &&
152
+        !element.isDeleted &&
153
+        this.savingFiles.has(element.fileId)
154
+      );
155
+    });
156
+  };
157
+
158
+  /**
159
+   * helper to determine if image element status needs updating
160
+   */
161
+  shouldUpdateImageElementStatus = (
162
+    element: ExcalidrawElement,
163
+  ): element is InitializedExcalidrawImageElement => {
164
+    return (
165
+      isInitializedImageElement(element) &&
166
+      this.isFileSaved(element.fileId) &&
167
+      element.status === "pending"
168
+    );
169
+  };
170
+
171
+  reset() {
172
+    this.fetchingFiles.clear();
173
+    this.savingFiles.clear();
174
+    this.savedFiles.clear();
175
+    this.erroredFiles.clear();
176
+  }
177
+}
178
+
179
+export const encodeFilesForUpload = async ({
180
+  files,
181
+  maxBytes,
182
+  encryptionKey,
183
+}: {
184
+  files: Map<FileId, BinaryFileData>;
185
+  maxBytes: number;
186
+  encryptionKey: string;
187
+}) => {
188
+  const processedFiles: {
189
+    id: FileId;
190
+    buffer: Uint8Array;
191
+  }[] = [];
192
+
193
+  for (const [id, fileData] of files) {
194
+    const buffer = new TextEncoder().encode(fileData.dataURL);
195
+
196
+    const encodedFile = await compressData<BinaryFileMetadata>(buffer, {
197
+      encryptionKey,
198
+      metadata: {
199
+        id,
200
+        mimeType: fileData.mimeType,
201
+        created: Date.now(),
202
+      },
203
+    });
204
+
205
+    if (buffer.byteLength > maxBytes) {
206
+      throw new Error(
207
+        t("errors.fileTooBig", {
208
+          maxSize: `${Math.trunc(maxBytes / 1024 / 1024)}MB`,
209
+        }),
210
+      );
211
+    }
212
+
213
+    processedFiles.push({
214
+      id,
215
+      buffer: encodedFile,
216
+    });
217
+  }
218
+
219
+  return processedFiles;
220
+};
221
+
222
+export const updateStaleImageStatuses = (params: {
223
+  excalidrawAPI: ExcalidrawImperativeAPI;
224
+  erroredFiles: Map<FileId, true>;
225
+  elements: readonly ExcalidrawElement[];
226
+}) => {
227
+  if (!params.erroredFiles.size) {
228
+    return;
229
+  }
230
+  params.excalidrawAPI.updateScene({
231
+    elements: params.excalidrawAPI
232
+      .getSceneElementsIncludingDeleted()
233
+      .map((element) => {
234
+        if (
235
+          isInitializedImageElement(element) &&
236
+          params.erroredFiles.has(element.fileId)
237
+        ) {
238
+          return mutateElement(
239
+            element,
240
+            {
241
+              status: "error",
242
+            },
243
+            false,
244
+          );
245
+        }
246
+        return element;
247
+      }),
248
+  });
249
+};

+ 118
- 12
src/excalidraw-app/data/firebase.ts View File

@@ -1,26 +1,45 @@
1
-import { getImportedKey } from "../data";
2
-import { createIV } from "./index";
3
-import { ExcalidrawElement } from "../../element/types";
1
+import { ExcalidrawElement, FileId } from "../../element/types";
4 2
 import { getSceneVersion } from "../../element";
5 3
 import Portal from "../collab/Portal";
6 4
 import { restoreElements } from "../../data/restore";
5
+import { BinaryFileData, BinaryFileMetadata, DataURL } from "../../types";
6
+import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
7
+import { decompressData } from "../../data/encode";
8
+import { getImportedKey, createIV } from "../../data/encryption";
9
+import { MIME_TYPES } from "../../constants";
7 10
 
8 11
 // private
9 12
 // -----------------------------------------------------------------------------
10 13
 
14
+const FIREBASE_CONFIG = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
15
+
11 16
 let firebasePromise: Promise<
12 17
   typeof import("firebase/app").default
13 18
 > | null = null;
14
-let firestorePromise: Promise<any> | null = null;
15
-let firebseStoragePromise: Promise<any> | null = null;
19
+let firestorePromise: Promise<any> | null | true = null;
20
+let firebaseStoragePromise: Promise<any> | null | true = null;
21
+
22
+let isFirebaseInitialized = false;
16 23
 
17 24
 const _loadFirebase = async () => {
18 25
   const firebase = (
19 26
     await import(/* webpackChunkName: "firebase" */ "firebase/app")
20 27
   ).default;
21 28
 
22
-  const firebaseConfig = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
23
-  firebase.initializeApp(firebaseConfig);
29
+  if (!isFirebaseInitialized) {
30
+    try {
31
+      firebase.initializeApp(FIREBASE_CONFIG);
32
+    } catch (error) {
33
+      // trying initialize again throws. Usually this is harmless, and happens
34
+      // mainly in dev (HMR)
35
+      if (error.code === "app/duplicate-app") {
36
+        console.warn(error.name, error.code);
37
+      } else {
38
+        throw error;
39
+      }
40
+    }
41
+    isFirebaseInitialized = true;
42
+  }
24 43
 
25 44
   return firebase;
26 45
 };
@@ -42,18 +61,24 @@ const loadFirestore = async () => {
42 61
     firestorePromise = import(
43 62
       /* webpackChunkName: "firestore" */ "firebase/firestore"
44 63
     );
64
+  }
65
+  if (firestorePromise !== true) {
45 66
     await firestorePromise;
67
+    firestorePromise = true;
46 68
   }
47 69
   return firebase;
48 70
 };
49 71
 
50 72
 export const loadFirebaseStorage = async () => {
51 73
   const firebase = await _getFirebase();
52
-  if (!firebseStoragePromise) {
53
-    firebseStoragePromise = import(
74
+  if (!firebaseStoragePromise) {
75
+    firebaseStoragePromise = import(
54 76
       /* webpackChunkName: "storage" */ "firebase/storage"
55 77
     );
56
-    await firebseStoragePromise;
78
+  }
79
+  if (firebaseStoragePromise !== true) {
80
+    await firebaseStoragePromise;
81
+    firebaseStoragePromise = true;
57 82
   }
58 83
   return firebase;
59 84
 };
@@ -87,7 +112,7 @@ const encryptElements = async (
87 112
 const decryptElements = async (
88 113
   key: string,
89 114
   iv: Uint8Array,
90
-  ciphertext: ArrayBuffer,
115
+  ciphertext: ArrayBuffer | Uint8Array,
91 116
 ): Promise<readonly ExcalidrawElement[]> => {
92 117
   const importedKey = await getImportedKey(key, "decrypt");
93 118
   const decrypted = await window.crypto.subtle.decrypt(
@@ -100,7 +125,7 @@ const decryptElements = async (
100 125
   );
101 126
 
102 127
   const decodedData = new TextDecoder("utf-8").decode(
103
-    new Uint8Array(decrypted) as any,
128
+    new Uint8Array(decrypted),
104 129
   );
105 130
   return JSON.parse(decodedData);
106 131
 };
@@ -113,6 +138,7 @@ export const isSavedToFirebase = (
113 138
 ): boolean => {
114 139
   if (portal.socket && portal.roomId && portal.roomKey) {
115 140
     const sceneVersion = getSceneVersion(elements);
141
+
116 142
     return firebaseSceneVersionCache.get(portal.socket) === sceneVersion;
117 143
   }
118 144
   // if no room exists, consider the room saved so that we don't unnecessarily
@@ -120,6 +146,42 @@ export const isSavedToFirebase = (
120 146
   return true;
121 147
 };
122 148
 
149
+export const saveFilesToFirebase = async ({
150
+  prefix,
151
+  files,
152
+}: {
153
+  prefix: string;
154
+  files: { id: FileId; buffer: Uint8Array }[];
155
+}) => {
156
+  const firebase = await loadFirebaseStorage();
157
+
158
+  const erroredFiles = new Map<FileId, true>();
159
+  const savedFiles = new Map<FileId, true>();
160
+
161
+  await Promise.all(
162
+    files.map(async ({ id, buffer }) => {
163
+      try {
164
+        await firebase
165
+          .storage()
166
+          .ref(`${prefix}/${id}`)
167
+          .put(
168
+            new Blob([buffer], {
169
+              type: MIME_TYPES.binary,
170
+            }),
171
+            {
172
+              cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
173
+            },
174
+          );
175
+        savedFiles.set(id, true);
176
+      } catch (error) {
177
+        erroredFiles.set(id, true);
178
+      }
179
+    }),
180
+  );
181
+
182
+  return { savedFiles, erroredFiles };
183
+};
184
+
123 185
 export const saveToFirebase = async (
124 186
   portal: Portal,
125 187
   elements: readonly ExcalidrawElement[],
@@ -198,3 +260,47 @@ export const loadFromFirebase = async (
198 260
 
199 261
   return restoreElements(elements, null);
200 262
 };
263
+
264
+export const loadFilesFromFirebase = async (
265
+  prefix: string,
266
+  decryptionKey: string,
267
+  filesIds: readonly FileId[],
268
+) => {
269
+  const loadedFiles: BinaryFileData[] = [];
270
+  const erroredFiles = new Map<FileId, true>();
271
+
272
+  await Promise.all(
273
+    [...new Set(filesIds)].map(async (id) => {
274
+      try {
275
+        const url = `https://firebasestorage.googleapis.com/v0/b/${
276
+          FIREBASE_CONFIG.storageBucket
277
+        }/o/${encodeURIComponent(prefix.replace(/^\//, ""))}%2F${id}`;
278
+        const response = await fetch(`${url}?alt=media`);
279
+        if (response.status < 400) {
280
+          const arrayBuffer = await response.arrayBuffer();
281
+
282
+          const { data, metadata } = await decompressData<BinaryFileMetadata>(
283
+            new Uint8Array(arrayBuffer),
284
+            {
285
+              decryptionKey,
286
+            },
287
+          );
288
+
289
+          const dataURL = new TextDecoder().decode(data) as DataURL;
290
+
291
+          loadedFiles.push({
292
+            mimeType: metadata.mimeType || MIME_TYPES.binary,
293
+            id,
294
+            dataURL,
295
+            created: metadata?.created || Date.now(),
296
+          });
297
+        }
298
+      } catch (error) {
299
+        erroredFiles.set(id, true);
300
+        console.error(error);
301
+      }
302
+    }),
303
+  );
304
+
305
+  return { loadedFiles, erroredFiles };
306
+};

+ 51
- 47
src/excalidraw-app/data/index.ts View File

@@ -1,9 +1,24 @@
1
+import {
2
+  createIV,
3
+  generateEncryptionKey,
4
+  getImportedKey,
5
+  IV_LENGTH_BYTES,
6
+} from "../../data/encryption";
1 7
 import { serializeAsJSON } from "../../data/json";
2 8
 import { restore } from "../../data/restore";
3 9
 import { ImportedDataState } from "../../data/types";
4
-import { ExcalidrawElement } from "../../element/types";
10
+import { isInitializedImageElement } from "../../element/typeChecks";
11
+import { ExcalidrawElement, FileId } from "../../element/types";
5 12
 import { t } from "../../i18n";
6
-import { AppState, UserIdleState } from "../../types";
13
+import {
14
+  AppState,
15
+  BinaryFileData,
16
+  BinaryFiles,
17
+  UserIdleState,
18
+} from "../../types";
19
+import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
20
+import { encodeFilesForUpload } from "./FileManager";
21
+import { saveFilesToFirebase } from "./firebase";
7 22
 
8 23
 const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
9 24
 
@@ -17,18 +32,6 @@ const generateRandomID = async () => {
17 32
   return Array.from(arr, byteToHex).join("");
18 33
 };
19 34
 
20
-export const generateEncryptionKey = async () => {
21
-  const key = await window.crypto.subtle.generateKey(
22
-    {
23
-      name: "AES-GCM",
24
-      length: 128,
25
-    },
26
-    true, // extractable
27
-    ["encrypt", "decrypt"],
28
-  );
29
-  return (await window.crypto.subtle.exportKey("jwk", key)).k;
30
-};
31
-
32 35
 export const SOCKET_SERVER = process.env.REACT_APP_SOCKET_SERVER_URL;
33 36
 
34 37
 export type EncryptedData = {
@@ -79,13 +82,6 @@ export type SocketUpdateData = SocketUpdateDataSource[keyof SocketUpdateDataSour
79 82
   _brand: "socketUpdateData";
80 83
 };
81 84
 
82
-const IV_LENGTH_BYTES = 12; // 96 bits
83
-
84
-export const createIV = () => {
85
-  const arr = new Uint8Array(IV_LENGTH_BYTES);
86
-  return window.crypto.getRandomValues(arr);
87
-};
88
-
89 85
 export const encryptAESGEM = async (
90 86
   data: Uint8Array,
91 87
   key: string,
@@ -122,7 +118,7 @@ export const decryptAESGEM = async (
122 118
     );
123 119
 
124 120
     const decodedData = new TextDecoder("utf-8").decode(
125
-      new Uint8Array(decrypted) as any,
121
+      new Uint8Array(decrypted),
126 122
     );
127 123
     return JSON.parse(decodedData);
128 124
   } catch (error) {
@@ -162,26 +158,8 @@ export const getCollaborationLink = (data: {
162 158
   return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`;
163 159
 };
164 160
 
165
-export const getImportedKey = (key: string, usage: KeyUsage) =>
166
-  window.crypto.subtle.importKey(
167
-    "jwk",
168
-    {
169
-      alg: "A128GCM",
170
-      ext: true,
171
-      k: key,
172
-      key_ops: ["encrypt", "decrypt"],
173
-      kty: "oct",
174
-    },
175
-    {
176
-      name: "AES-GCM",
177
-      length: 128,
178
-    },
179
-    false, // extractable
180
-    [usage],
181
-  );
182
-
183 161
 export const decryptImported = async (
184
-  iv: ArrayBuffer,
162
+  iv: ArrayBuffer | Uint8Array,
185 163
   encrypted: ArrayBuffer,
186 164
   privateKey: string,
187 165
 ): Promise<ArrayBuffer> => {
@@ -227,7 +205,7 @@ const importFromBackend = async (
227 205
 
228 206
       // We need to convert the decrypted array buffer to a string
229 207
       const string = new window.TextDecoder("utf-8").decode(
230
-        new Uint8Array(decrypted) as any,
208
+        new Uint8Array(decrypted),
231 209
       );
232 210
       data = JSON.parse(string);
233 211
     } else {
@@ -270,6 +248,10 @@ export const loadScene = async (
270 248
   return {
271 249
     elements: data.elements,
272 250
     appState: data.appState,
251
+    // note: this will always be empty because we're not storing files
252
+    // in the scene database/localStorage, and instead fetch them async
253
+    // from a different database
254
+    files: data.files,
273 255
     commitToHistory: false,
274 256
   };
275 257
 };
@@ -277,11 +259,12 @@ export const loadScene = async (
277 259
 export const exportToBackend = async (
278 260
   elements: readonly ExcalidrawElement[],
279 261
   appState: AppState,
262
+  files: BinaryFiles,
280 263
 ) => {
281
-  const json = serializeAsJSON(elements, appState);
264
+  const json = serializeAsJSON(elements, appState, files, "database");
282 265
   const encoded = new TextEncoder().encode(json);
283 266
 
284
-  const key = await window.crypto.subtle.generateKey(
267
+  const cryptoKey = await window.crypto.subtle.generateKey(
285 268
     {
286 269
       name: "AES-GCM",
287 270
       length: 128,
@@ -298,7 +281,7 @@ export const exportToBackend = async (
298 281
       name: "AES-GCM",
299 282
       iv,
300 283
     },
301
-    key,
284
+    cryptoKey,
302 285
     encoded,
303 286
   );
304 287
 
@@ -308,9 +291,24 @@ export const exportToBackend = async (
308 291
 
309 292
   // We use jwk encoding to be able to extract just the base64 encoded key.
310 293
   // We will hardcode the rest of the attributes when importing back the key.
311
-  const exportedKey = await window.crypto.subtle.exportKey("jwk", key);
294
+  const exportedKey = await window.crypto.subtle.exportKey("jwk", cryptoKey);
312 295
 
313 296
   try {
297
+    const filesMap = new Map<FileId, BinaryFileData>();
298
+    for (const element of elements) {
299
+      if (isInitializedImageElement(element) && files[element.fileId]) {
300
+        filesMap.set(element.fileId, files[element.fileId]);
301
+      }
302
+    }
303
+
304
+    const encryptionKey = exportedKey.k!;
305
+
306
+    const filesToUpload = await encodeFilesForUpload({
307
+      files: filesMap,
308
+      encryptionKey,
309
+      maxBytes: FILE_UPLOAD_MAX_BYTES,
310
+    });
311
+
314 312
     const response = await fetch(BACKEND_V2_POST, {
315 313
       method: "POST",
316 314
       body: payload,
@@ -320,8 +318,14 @@ export const exportToBackend = async (
320 318
       const url = new URL(window.location.href);
321 319
       // We need to store the key (and less importantly the id) as hash instead
322 320
       // of queryParam in order to never send it to the server
323
-      url.hash = `json=${json.id},${exportedKey.k!}`;
321
+      url.hash = `json=${json.id},${encryptionKey}`;
324 322
       const urlString = url.toString();
323
+
324
+      await saveFilesToFirebase({
325
+        prefix: `/files/shareLinks/${json.id}`,
326
+        files: filesToUpload,
327
+      });
328
+
325 329
       window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString);
326 330
     } else if (json.error_class === "RequestTooLargeError") {
327 331
       window.alert(t("alerts.couldNotCreateShareableLinkTooBig"));

+ 259
- 39
src/excalidraw-app/index.tsx View File

@@ -16,6 +16,7 @@ import { loadFromBlob } from "../data/blob";
16 16
 import { ImportedDataState } from "../data/types";
17 17
 import {
18 18
   ExcalidrawElement,
19
+  FileId,
19 20
   NonDeletedExcalidrawElement,
20 21
 } from "../element/types";
21 22
 import { useCallbackRefState } from "../hooks/useCallbackRefState";
@@ -24,14 +25,24 @@ import Excalidraw, {
24 25
   defaultLang,
25 26
   languages,
26 27
 } from "../packages/excalidraw/index";
27
-import { AppState, LibraryItems, ExcalidrawImperativeAPI } from "../types";
28
+import {
29
+  AppState,
30
+  LibraryItems,
31
+  ExcalidrawImperativeAPI,
32
+  BinaryFileData,
33
+  BinaryFiles,
34
+} from "../types";
28 35
 import {
29 36
   debounce,
30 37
   getVersion,
38
+  preventUnload,
31 39
   ResolvablePromise,
32 40
   resolvablePromise,
33 41
 } from "../utils";
34
-import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "./app_constants";
42
+import {
43
+  FIREBASE_STORAGE_PREFIXES,
44
+  SAVE_TO_LOCAL_STORAGE_TIMEOUT,
45
+} from "./app_constants";
35 46
 import CollabWrapper, {
36 47
   CollabAPI,
37 48
   CollabContext,
@@ -51,6 +62,64 @@ import { shield } from "../components/icons";
51 62
 import "./index.scss";
52 63
 import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
53 64
 
65
+import { getMany, set, del, keys, createStore } from "idb-keyval";
66
+import { FileManager, updateStaleImageStatuses } from "./data/FileManager";
67
+import { mutateElement } from "../element/mutateElement";
68
+import { isInitializedImageElement } from "../element/typeChecks";
69
+import { loadFilesFromFirebase } from "./data/firebase";
70
+
71
+const filesStore = createStore("files-db", "files-store");
72
+
73
+const clearObsoleteFilesFromIndexedDB = async (opts: {
74
+  currentFileIds: FileId[];
75
+}) => {
76
+  const allIds = await keys(filesStore);
77
+  for (const id of allIds) {
78
+    if (!opts.currentFileIds.includes(id as FileId)) {
79
+      del(id, filesStore);
80
+    }
81
+  }
82
+};
83
+
84
+const localFileStorage = new FileManager({
85
+  getFiles(ids) {
86
+    return getMany(ids, filesStore).then(
87
+      (filesData: (BinaryFileData | undefined)[]) => {
88
+        const loadedFiles: BinaryFileData[] = [];
89
+        const erroredFiles = new Map<FileId, true>();
90
+        filesData.forEach((data, index) => {
91
+          const id = ids[index];
92
+          if (data) {
93
+            loadedFiles.push(data);
94
+          } else {
95
+            erroredFiles.set(id, true);
96
+          }
97
+        });
98
+
99
+        return { loadedFiles, erroredFiles };
100
+      },
101
+    );
102
+  },
103
+  async saveFiles({ addedFiles }) {
104
+    const savedFiles = new Map<FileId, true>();
105
+    const erroredFiles = new Map<FileId, true>();
106
+
107
+    await Promise.all(
108
+      [...addedFiles].map(async ([id, fileData]) => {
109
+        try {
110
+          await set(id, fileData, filesStore);
111
+          savedFiles.set(id, true);
112
+        } catch (error) {
113
+          console.error(error);
114
+          erroredFiles.set(id, true);
115
+        }
116
+      }),
117
+    );
118
+
119
+    return { savedFiles, erroredFiles };
120
+  },
121
+});
122
+
54 123
 const languageDetector = new LanguageDetector();
55 124
 languageDetector.init({
56 125
   languageUtils: {
@@ -61,8 +130,20 @@ languageDetector.init({
61 130
 });
62 131
 
63 132
 const saveDebounced = debounce(
64
-  (elements: readonly ExcalidrawElement[], state: AppState) => {
65
-    saveToLocalStorage(elements, state);
133
+  async (
134
+    elements: readonly ExcalidrawElement[],
135
+    appState: AppState,
136
+    files: BinaryFiles,
137
+    onFilesSaved: () => void,
138
+  ) => {
139
+    saveToLocalStorage(elements, appState);
140
+
141
+    await localFileStorage.saveFiles({
142
+      elements,
143
+      files,
144
+    });
145
+
146
+    onFilesSaved();
66 147
   },
67 148
   SAVE_TO_LOCAL_STORAGE_TIMEOUT,
68 149
 );
@@ -73,7 +154,12 @@ const onBlur = () => {
73 154
 
74 155
 const initializeScene = async (opts: {
75 156
   collabAPI: CollabAPI;
76
-}): Promise<ImportedDataState | null> => {
157
+}): Promise<
158
+  { scene: ImportedDataState | null } & (
159
+    | { isExternalScene: true; id: string; key: string }
160
+    | { isExternalScene: false; id?: null; key?: null }
161
+  )
162
+> => {
77 163
   const searchParams = new URLSearchParams(window.location.search);
78 164
   const id = searchParams.get("id");
79 165
   const jsonBackendMatch = window.location.hash.match(
@@ -140,23 +226,38 @@ const initializeScene = async (opts: {
140 226
         !scene.elements.length ||
141 227
         window.confirm(t("alerts.loadSceneOverridePrompt"))
142 228
       ) {
143
-        return data;
229
+        return { scene: data, isExternalScene };
144 230
       }
145 231
     } catch (error) {
146 232
       return {
147
-        appState: {
148
-          errorMessage: t("alerts.invalidSceneUrl"),
233
+        scene: {
234
+          appState: {
235
+            errorMessage: t("alerts.invalidSceneUrl"),
236
+          },
149 237
         },
238
+        isExternalScene,
150 239
       };
151 240
     }
152 241
   }
153 242
 
154 243
   if (roomLinkData) {
155
-    return opts.collabAPI.initializeSocketClient(roomLinkData);
244
+    return {
245
+      scene: await opts.collabAPI.initializeSocketClient(roomLinkData),
246
+      isExternalScene: true,
247
+      id: roomLinkData.roomId,
248
+      key: roomLinkData.roomKey,
249
+    };
156 250
   } else if (scene) {
157
-    return scene;
251
+    return isExternalScene && jsonBackendMatch
252
+      ? {
253
+          scene,
254
+          isExternalScene,
255
+          id: jsonBackendMatch[1],
256
+          key: jsonBackendMatch[2],
257
+        }
258
+      : { scene, isExternalScene: false };
158 259
   }
159
-  return null;
260
+  return { scene: null, isExternalScene: false };
160 261
 };
161 262
 
162 263
 const PlusLinkJSX = (
@@ -207,20 +308,84 @@ const ExcalidrawWrapper = () => {
207 308
       return;
208 309
     }
209 310
 
210
-    initializeScene({ collabAPI }).then((scene) => {
211
-      if (scene) {
212
-        try {
213
-          scene.libraryItems =
214
-            JSON.parse(
215
-              localStorage.getItem(
216
-                STORAGE_KEYS.LOCAL_STORAGE_LIBRARY,
217
-              ) as string,
218
-            ) || [];
219
-        } catch (e) {
220
-          console.error(e);
311
+    const loadImages = (
312
+      data: ResolutionType<typeof initializeScene>,
313
+      isInitialLoad = false,
314
+    ) => {
315
+      if (!data.scene) {
316
+        return;
317
+      }
318
+      if (collabAPI.isCollaborating()) {
319
+        if (data.scene.elements) {
320
+          collabAPI
321
+            .fetchImageFilesFromFirebase({
322
+              elements: data.scene.elements,
323
+            })
324
+            .then(({ loadedFiles, erroredFiles }) => {
325
+              excalidrawAPI.addFiles(loadedFiles);
326
+              updateStaleImageStatuses({
327
+                excalidrawAPI,
328
+                erroredFiles,
329
+                elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
330
+              });
331
+            });
332
+        }
333
+      } else {
334
+        const fileIds =
335
+          data.scene.elements?.reduce((acc, element) => {
336
+            if (isInitializedImageElement(element)) {
337
+              return acc.concat(element.fileId);
338
+            }
339
+            return acc;
340
+          }, [] as FileId[]) || [];
341
+
342
+        if (data.isExternalScene) {
343
+          loadFilesFromFirebase(
344
+            `${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
345
+            data.key,
346
+            fileIds,
347
+          ).then(({ loadedFiles, erroredFiles }) => {
348
+            excalidrawAPI.addFiles(loadedFiles);
349
+            updateStaleImageStatuses({
350
+              excalidrawAPI,
351
+              erroredFiles,
352
+              elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
353
+            });
354
+          });
355
+        } else if (isInitialLoad) {
356
+          if (fileIds.length) {
357
+            localFileStorage
358
+              .getFiles(fileIds)
359
+              .then(({ loadedFiles, erroredFiles }) => {
360
+                if (loadedFiles.length) {
361
+                  excalidrawAPI.addFiles(loadedFiles);
362
+                }
363
+                updateStaleImageStatuses({
364
+                  excalidrawAPI,
365
+                  erroredFiles,
366
+                  elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
367
+                });
368
+              });
369
+          }
370
+          // on fresh load, clear unused files from IDB (from previous
371
+          // session)
372
+          clearObsoleteFilesFromIndexedDB({ currentFileIds: fileIds });
221 373
         }
222 374
       }
223
-      initialStatePromiseRef.current.promise.resolve(scene);
375
+
376
+      try {
377
+        data.scene.libraryItems =
378
+          JSON.parse(
379
+            localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
380
+          ) || [];
381
+      } catch (e) {
382
+        console.error(e);
383
+      }
384
+    };
385
+
386
+    initializeScene({ collabAPI }).then((data) => {
387
+      loadImages(data, /* isInitialLoad */ true);
388
+      initialStatePromiseRef.current.promise.resolve(data.scene);
224 389
     });
225 390
 
226 391
     const onHashChange = (event: HashChangeEvent) => {
@@ -235,11 +400,12 @@ const ExcalidrawWrapper = () => {
235 400
         window.history.replaceState({}, "", event.oldURL);
236 401
         excalidrawAPI.importLibrary(libraryUrl, hash.get("token"));
237 402
       } else {
238
-        initializeScene({ collabAPI }).then((scene) => {
239
-          if (scene) {
403
+        initializeScene({ collabAPI }).then((data) => {
404
+          loadImages(data);
405
+          if (data.scene) {
240 406
             excalidrawAPI.updateScene({
241
-              ...scene,
242
-              appState: restoreAppState(scene.appState, null),
407
+              ...data.scene,
408
+              appState: restoreAppState(data.scene.appState, null),
243 409
             });
244 410
           }
245 411
         });
@@ -261,6 +427,23 @@ const ExcalidrawWrapper = () => {
261 427
     };
262 428
   }, [collabAPI, excalidrawAPI]);
263 429
 
430
+  useEffect(() => {
431
+    const unloadHandler = (event: BeforeUnloadEvent) => {
432
+      saveDebounced.flush();
433
+
434
+      if (
435
+        excalidrawAPI &&
436
+        localFileStorage.shouldPreventUnload(excalidrawAPI.getSceneElements())
437
+      ) {
438
+        preventUnload(event);
439
+      }
440
+    };
441
+    window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
442
+    return () => {
443
+      window.removeEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
444
+    };
445
+  }, [excalidrawAPI]);
446
+
264 447
   useEffect(() => {
265 448
     languageDetector.cacheUserLanguage(langCode);
266 449
   }, [langCode]);
@@ -268,20 +451,43 @@ const ExcalidrawWrapper = () => {
268 451
   const onChange = (
269 452
     elements: readonly ExcalidrawElement[],
270 453
     appState: AppState,
454
+    files: BinaryFiles,
271 455
   ) => {
272 456
     if (collabAPI?.isCollaborating()) {
273 457
       collabAPI.broadcastElements(elements);
274 458
     } else {
275
-      // collab scenes are persisted to the server, so we don't have to persist
276
-      // them locally, which has the added benefit of not overwriting whatever
277
-      // the user was working on before joining
278
-      saveDebounced(elements, appState);
459
+      saveDebounced(elements, appState, files, () => {
460
+        if (excalidrawAPI) {
461
+          let didChange = false;
462
+
463
+          const elements = excalidrawAPI
464
+            .getSceneElementsIncludingDeleted()
465
+            .map((element) => {
466
+              if (localFileStorage.shouldUpdateImageElementStatus(element)) {
467
+                didChange = true;
468
+                return mutateElement(
469
+                  element,
470
+                  { status: "saved" },
471
+                  /* informMutation */ false,
472
+                );
473
+              }
474
+              return element;
475
+            });
476
+
477
+          if (didChange) {
478
+            excalidrawAPI.updateScene({
479
+              elements,
480
+            });
481
+          }
482
+        }
483
+      });
279 484
     }
280 485
   };
281 486
 
282 487
   const onExportToBackend = async (
283 488
     exportedElements: readonly NonDeletedExcalidrawElement[],
284 489
     appState: AppState,
490
+    files: BinaryFiles,
285 491
     canvas: HTMLCanvasElement | null,
286 492
   ) => {
287 493
     if (exportedElements.length === 0) {
@@ -289,12 +495,16 @@ const ExcalidrawWrapper = () => {
289 495
     }
290 496
     if (canvas) {
291 497
       try {
292
-        await exportToBackend(exportedElements, {
293
-          ...appState,
294
-          viewBackgroundColor: appState.exportBackground
295
-            ? appState.viewBackgroundColor
296
-            : getDefaultAppState().viewBackgroundColor,
297
-        });
498
+        await exportToBackend(
499
+          exportedElements,
500
+          {
501
+            ...appState,
502
+            viewBackgroundColor: appState.exportBackground
503
+              ? appState.viewBackgroundColor
504
+              : getDefaultAppState().viewBackgroundColor,
505
+          },
506
+          files,
507
+        );
298 508
       } catch (error) {
299 509
         if (error.name !== "AbortError") {
300 510
           const { width, height } = canvas;
@@ -409,6 +619,10 @@ const ExcalidrawWrapper = () => {
409 619
     localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
410 620
   };
411 621
 
622
+  const onRoomClose = useCallback(() => {
623
+    localFileStorage.reset();
624
+  }, []);
625
+
412 626
   return (
413 627
     <>
414 628
       <Excalidraw
@@ -422,11 +636,12 @@ const ExcalidrawWrapper = () => {
422 636
           canvasActions: {
423 637
             export: {
424 638
               onExportToBackend,
425
-              renderCustomUI: (elements, appState) => {
639
+              renderCustomUI: (elements, appState, files) => {
426 640
                 return (
427 641
                   <ExportToExcalidrawPlus
428 642
                     elements={elements}
429 643
                     appState={appState}
644
+                    files={files}
430 645
                     onError={(error) => {
431 646
                       excalidrawAPI?.updateScene({
432 647
                         appState: {
@@ -449,7 +664,12 @@ const ExcalidrawWrapper = () => {
449 664
         onLibraryChange={onLibraryChange}
450 665
         autoFocus={true}
451 666
       />
452
-      {excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
667
+      {excalidrawAPI && (
668
+        <CollabWrapper
669
+          excalidrawAPI={excalidrawAPI}
670
+          onRoomClose={onRoomClose}
671
+        />
672
+      )}
453 673
       {errorMessage && (
454 674
         <ErrorDialog
455 675
           message={errorMessage}

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

@@ -47,6 +47,11 @@ type MarkOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
47 47
 type MarkRequired<T, RK extends keyof T> = Exclude<T, RK> &
48 48
   Required<Pick<T, RK>>;
49 49
 
50
+type MarkNonNullable<T, K extends keyof T> = {
51
+  [P in K]-?: P extends K ? NonNullable<T[P]> : T[P];
52
+} &
53
+  { [P in keyof T]: T[P] };
54
+
50 55
 // PNG encoding/decoding
51 56
 // -----------------------------------------------------------------------------
52 57
 type TEXtChunk = { name: "tEXt"; data: Uint8Array };
@@ -91,3 +96,37 @@ interface Blob {
91 96
 }
92 97
 
93 98
 declare module "*.scss";
99
+
100
+// --------------------------------------------------------------------------—
101
+// ensure Uint8Array isn't assignable to ArrayBuffer
102
+// (due to TS structural typing)
103
+// https://github.com/microsoft/TypeScript/issues/31311#issuecomment-490690695
104
+interface ArrayBuffer {
105
+  private _brand?: "ArrayBuffer";
106
+}
107
+interface Uint8Array {
108
+  private _brand?: "Uint8Array";
109
+}
110
+// --------------------------------------------------------------------------—
111
+
112
+// https://github.com/nodeca/image-blob-reduce/issues/23#issuecomment-783271848
113
+declare module "image-blob-reduce" {
114
+  import { PicaResizeOptions } from "pica";
115
+  namespace ImageBlobReduce {
116
+    interface ImageBlobReduce {
117
+      toBlob(file: File, options: ImageBlobReduceOptions): Promise<Blob>;
118
+    }
119
+
120
+    interface ImageBlobReduceStatic {
121
+      new (options?: any): ImageBlobReduce;
122
+
123
+      (options?: any): ImageBlobReduce;
124
+    }
125
+
126
+    interface ImageBlobReduceOptions extends PicaResizeOptions {
127
+      max: number;
128
+    }
129
+  }
130
+  const reduce: ImageBlobReduce.ImageBlobReduceStatic;
131
+  export = reduce;
132
+}

+ 1
- 0
src/index-node.ts View File

@@ -66,6 +66,7 @@ const canvas = exportToCanvas(
66 66
     width: 0,
67 67
     height: 0,
68 68
   },
69
+  {}, // files
69 70
   {
70 71
     exportBackground: true,
71 72
     viewBackgroundColor: "#ffffff",

+ 5
- 5
src/keys.ts View File

@@ -45,6 +45,7 @@ export const KEYS = {
45 45
   D: "d",
46 46
   E: "e",
47 47
   G: "g",
48
+  I: "i",
48 49
   L: "l",
49 50
   O: "o",
50 51
   P: "p",
@@ -66,13 +67,12 @@ export const isArrowKey = (key: string) =>
66 67
   key === KEYS.ARROW_DOWN ||
67 68
   key === KEYS.ARROW_UP;
68 69
 
69
-export const getResizeCenterPointKey = (event: MouseEvent | KeyboardEvent) =>
70
+export const shouldResizeFromCenter = (event: MouseEvent | KeyboardEvent) =>
70 71
   event.altKey;
71 72
 
72
-export const getResizeWithSidesSameLengthKey = (
73
-  event: MouseEvent | KeyboardEvent,
74
-) => event.shiftKey;
73
+export const shouldMaintainAspectRatio = (event: MouseEvent | KeyboardEvent) =>
74
+  event.shiftKey;
75 75
 
76
-export const getRotateWithDiscreteAngleKey = (
76
+export const shouldRotateWithDiscreteAngle = (
77 77
   event: MouseEvent | KeyboardEvent,
78 78
 ) => event.shiftKey;

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

@@ -156,14 +156,22 @@
156 156
     "errorAddingToLibrary": "Couldn't add item to the library",
157 157
     "errorRemovingFromLibrary": "Couldn't remove item from the library",
158 158
     "confirmAddLibrary": "This will add {{numShapes}} shape(s) to your library. Are you sure?",
159
-    "imageDoesNotContainScene": "Importing images isn't supported at the moment.\n\nDid you want to import a scene? This image does not seem to contain any scene data. Have you enabled this during export?",
159
+    "imageDoesNotContainScene": "This image does not seem to contain any scene data. Have you enabled scene embedding during export?",
160 160
     "cannotRestoreFromImage": "Scene couldn't be restored from this image file",
161 161
     "invalidSceneUrl": "Couldn't import scene from the supplied URL. It's either malformed, or doesn't contain valid Excalidraw JSON data.",
162 162
     "resetLibrary": "This will clear your library. Are you sure?",
163 163
     "invalidEncryptionKey": "Encryption key must be of 22 characters. Live collaboration is disabled."
164 164
   },
165
+  "errors": {
166
+    "unsupportedFileType": "Unsupported file type.",
167
+    "imageInsertError": "Couldn't insert image. Try again later...",
168
+    "fileTooBig": "File is too big. Maximum allowed size is {{maxSize}}.",
169
+    "svgImageInsertError": "Couldn't insert SVG image. The SVG markup looks invalid.",
170
+    "invalidSVGString": "errors.invalidSVGString"
171
+  },
165 172
   "toolBar": {
166 173
     "selection": "Selection",
174
+    "image": "Insert image",
167 175
     "rectangle": "Rectangle",
168 176
     "diamond": "Diamond",
169 177
     "ellipse": "Ellipse",
@@ -188,10 +196,12 @@
188 196
     "linearElementMulti": "Click on last point or press Escape or Enter to finish",
189 197
     "lockAngle": "You can constrain angle by holding SHIFT",
190 198
     "resize": "You can constrain proportions by holding SHIFT while resizing,\nhold ALT to resize from the center",
199
+    "resizeImage": "You can resize freely by holding SHIFT,\nhold ALT to resize from the center",
191 200
     "rotate": "You can constrain angles by holding SHIFT while rotating",
192 201
     "lineEditor_info": "Double-click or press Enter to edit points",
193 202
     "lineEditor_pointSelected": "Press Delete to remove point, CtrlOrCmd+D to duplicate, or drag to move",
194
-    "lineEditor_nothingSelected": "Select a point to move or remove, or hold Alt and click to add new points"
203
+    "lineEditor_nothingSelected": "Select a point to move or remove, or hold Alt and click to add new points",
204
+    "placeImage": "Click to place the image, or click and drag to set its size manually"
195 205
   },
196 206
   "canvasError": {
197 207
     "cannotShowPreview": "Cannot show preview",

+ 25
- 0
src/packages/excalidraw/CHANGELOG.md View File

@@ -13,6 +13,30 @@ Please add the latest change on the top under the correct section.
13 13
 
14 14
 ## Unreleased
15 15
 
16
+- Image support.
17
+
18
+  NOTE: the unreleased API is highly unstable and may change significantly before the next stable release. As such it's largely undocumented at this point. You are encouraged to read through the [PR](https://github.com/excalidraw/excalidraw/pull/4011) description if you want to know more about the internals.
19
+
20
+  General notes:
21
+
22
+  - File data are encoded as DataURLs (base64) for portability reasons.
23
+
24
+  [ExcalidrawAPI](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#onLibraryChange):
25
+
26
+  - added `getFiles()` to get current `BinaryFiles` (`Record<FileId, BinaryFileData>`). It may contain files that aren't referenced by any element, so if you're persisting the files to a storage, you should compare them against stored elements.
27
+
28
+  Excalidraw app props:
29
+
30
+  - added `generateIdForFile(file: File)` optional prop so you can generate your own ids for added files.
31
+  - `onChange(elements, appState, files)` prop callback is now passed `BinaryFiles` as third argument.
32
+  - `onPaste(data, event)` data prop should contain `data.files` (`BinaryFiles`) if the elements pasted are referencing new files.
33
+  - `initialData` object now supports additional `files` (`BinaryFiles`) attribute.
34
+
35
+  Other notes:
36
+
37
+  - `.excalidraw` files may now contain top-level `files` key in format of `Record<FileId, BinaryFileData>` when exporting any (image) elements.
38
+  - Changes were made to various export utilityies exported from the package so that they take `files`. For now, TypeScript should help you figure the changes out.
39
+
16 40
 ## Excalidraw API
17 41
 
18 42
 ### Features
@@ -380,6 +404,7 @@ Please add the latest change on the top under the correct section.
380 404
 - #### BREAKING CHANGE
381 405
   Use `location.hash` when importing libraries to fix installation issues. This will require host apps to add a `hashchange` listener and call the newly exposed `excalidrawAPI.importLibrary(url)` API when applicable [#3320](https://github.com/excalidraw/excalidraw/pull/3320). Check the [readme](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#importlibrary) for more details.
382 406
 - Append `location.pathname` to `libraryReturnUrl` default url [#3325](https://github.com/excalidraw/excalidraw/pull/3325).
407
+- Support image elements [#3424](https://github.com/excalidraw/excalidraw/pull/3424).
383 408
 
384 409
 ### Build
385 410
 

+ 18
- 2
src/packages/excalidraw/README_NEXT.md View File

@@ -379,6 +379,7 @@ To view the full example visit :point_down:
379 379
 | [`handleKeyboardGlobally`](#handleKeyboardGlobally) | boolean | false | Indicates whether to bind the keyboard events to document. |
380 380
 | [`onLibraryChange`](#onLibraryChange) | <pre>(items: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L200">LibraryItems</a>) => void &#124; Promise&lt;any&gt; </pre> |  | The callback if supplied is triggered when the library is updated and receives the library items. |
381 381
 | [`autoFocus`](#autoFocus) | boolean | false | Implies whether to focus the Excalidraw component on page load |
382
+| [`generateIdForFile`](#generateIdForFile) | `(file: File) => string | Promise<string>` | Allows you to override `id` generation for files added on canvas |
382 383
 
383 384
 ### Dimensions of Excalidraw
384 385
 
@@ -448,7 +449,8 @@ You can pass a `ref` when you want to access some excalidraw APIs. We expose the
448 449
 | --- | --- | --- |
449 450
 | ready | `boolean` | This is set to true once Excalidraw is rendered |
450 451
 | readyPromise | [resolvablePromise](https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L317) | This promise will be resolved with the api once excalidraw has rendered. This will be helpful when you want do some action on the host app once this promise resolves. For this to work you will have to pass ref as shown [here](#readyPromise) |
451
-| [updateScene](#updateScene) | <pre>(<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L207">sceneData</a>)) => void </pre> | updates the scene with the sceneData |
452
+| [updateScene](#updateScene) | <pre>(scene: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L207">sceneData</a>) => void </pre> | updates the scene with the sceneData |
453
+| [addFiles](#addFiles) | <pre>(files: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts">BinaryFileData</a>) => void </pre> | add files data to the appState |
452 454
 | resetScene | `({ resetLoadingState: boolean }) => void` | Resets the scene. If `resetLoadingState` is passed as true then it will also force set the loading state to false. |
453 455
 | getSceneElementsIncludingDeleted | <pre> () => <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement[]</a></pre> | Returns all the elements including the deleted in the scene |
454 456
 | getSceneElements | <pre> () => <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L78">ExcalidrawElement[]</a></pre> | Returns all the elements excluding the deleted in the scene |
@@ -471,7 +473,7 @@ Since plain object is passed as a `ref`, the `readyPromise` is resolved as soon
471 473
 ### `updateScene`
472 474
 
473 475
 <pre>
474
-(<a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L207">sceneData</a>)) => void
476
+(scene: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L207">sceneData</a>) => void
475 477
 </pre>
476 478
 
477 479
 You can use this function to update the scene with the sceneData. It accepts the below attributes.
@@ -483,6 +485,12 @@ You can use this function to update the scene with the sceneData. It accepts the
483 485
 | `collaborators` | <pre>Map<string, <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L29">Collaborator></a></pre> | The list of collaborators to be updated in the scene. |
484 486
 | `commitToHistory` | `boolean` | Implies if the `history (undo/redo)` should be recorded. Defaults to `false`. |
485 487
 
488
+### `addFiles`
489
+
490
+<pre>(files: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts">BinaryFileData</a>) => void </pre>
491
+
492
+Adds supplied files data to the `appState.files` cache, on top of existing files present in the cache.
493
+
486 494
 #### `onCollabButtonClick`
487 495
 
488 496
 This callback is triggered when clicked on the collab button in excalidraw. If not supplied, the collab dialog button is not rendered.
@@ -662,6 +670,14 @@ The unique id of the excalidraw component. This can be used to identify the exca
662 670
 
663 671
 This prop implies whether to focus the Excalidraw component on page load. Defaults to false.
664 672
 
673
+#### `generateIdForFile`
674
+
675
+Allows you to override `id` generation for files added on canvas (images). By default, an SHA-1 digest of the file is used.
676
+
677
+```
678
+(file: File) => string | Promise<string>
679
+```
680
+
665 681
 ### Does it support collaboration ?
666 682
 
667 683
 No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/src/excalidraw-app/index.tsx).

+ 8
- 0
src/packages/excalidraw/index.tsx View File

@@ -34,6 +34,7 @@ const Excalidraw = (props: ExcalidrawProps) => {
34 34
     handleKeyboardGlobally = false,
35 35
     onLibraryChange,
36 36
     autoFocus = false,
37
+    generateIdForFile,
37 38
   } = props;
38 39
 
39 40
   const canvasActions = props.UIOptions?.canvasActions;
@@ -94,6 +95,7 @@ const Excalidraw = (props: ExcalidrawProps) => {
94 95
         handleKeyboardGlobally={handleKeyboardGlobally}
95 96
         onLibraryChange={onLibraryChange}
96 97
         autoFocus={autoFocus}
98
+        generateIdForFile={generateIdForFile}
97 99
       />
98 100
     </InitializeApp>
99 101
   );
@@ -187,3 +189,9 @@ export {
187 189
 export { isLinearElement } from "../../element/typeChecks";
188 190
 
189 191
 export { FONT_FAMILY, THEME } from "../../constants";
192
+
193
+export {
194
+  mutateElement,
195
+  newElementWith,
196
+  bumpVersion,
197
+} from "../../element/mutateElement";

+ 21
- 11
src/packages/utils.ts View File

@@ -3,14 +3,16 @@ import {
3 3
   exportToSvg as _exportToSvg,
4 4
 } from "../scene/export";
5 5
 import { getDefaultAppState } from "../appState";
6
-import { AppState } from "../types";
6
+import { AppState, BinaryFiles } from "../types";
7 7
 import { ExcalidrawElement } from "../element/types";
8 8
 import { getNonDeletedElements } from "../element";
9 9
 import { restore } from "../data/restore";
10
+import { MIME_TYPES } from "../constants";
10 11
 
11 12
 type ExportOpts = {
12 13
   elements: readonly ExcalidrawElement[];
13 14
   appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
15
+  files: BinaryFiles | null;
14 16
   getDimensions?: (
15 17
     width: number,
16 18
     height: number,
@@ -20,6 +22,7 @@ type ExportOpts = {
20 22
 export const exportToCanvas = ({
21 23
   elements,
22 24
   appState,
25
+  files,
23 26
   getDimensions = (width, height) => ({ width, height, scale: 1 }),
24 27
 }: ExportOpts) => {
25 28
   const { elements: restoredElements, appState: restoredAppState } = restore(
@@ -31,6 +34,7 @@ export const exportToCanvas = ({
31 34
   return _exportToCanvas(
32 35
     getNonDeletedElements(restoredElements),
33 36
     { ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 },
37
+    files || {},
34 38
     { exportBackground, viewBackgroundColor },
35 39
     (width: number, height: number) => {
36 40
       const canvas = document.createElement("canvas");
@@ -44,22 +48,23 @@ export const exportToCanvas = ({
44 48
   );
45 49
 };
46 50
 
47
-export const exportToBlob = (
51
+export const exportToBlob = async (
48 52
   opts: ExportOpts & {
49 53
     mimeType?: string;
50 54
     quality?: number;
51 55
   },
52 56
 ): Promise<Blob | null> => {
53
-  const canvas = exportToCanvas(opts);
57
+  const canvas = await exportToCanvas(opts);
54 58
 
55
-  let { mimeType = "image/png", quality } = opts;
59
+  let { mimeType = MIME_TYPES.png, quality } = opts;
56 60
 
57
-  if (mimeType === "image/png" && typeof quality === "number") {
58
-    console.warn(`"quality" will be ignored for "image/png" mimeType`);
61
+  if (mimeType === MIME_TYPES.png && typeof quality === "number") {
62
+    console.warn(`"quality" will be ignored for "${MIME_TYPES.png}" mimeType`);
59 63
   }
60 64
 
65
+  // typo in MIME type (should be "jpeg")
61 66
   if (mimeType === "image/jpg") {
62
-    mimeType = "image/jpeg";
67
+    mimeType = MIME_TYPES.jpg;
63 68
   }
64 69
 
65 70
   quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8;
@@ -78,6 +83,7 @@ export const exportToBlob = (
78 83
 export const exportToSvg = async ({
79 84
   elements,
80 85
   appState = getDefaultAppState(),
86
+  files = {},
81 87
   exportPadding,
82 88
 }: Omit<ExportOpts, "getDimensions"> & {
83 89
   exportPadding?: number;
@@ -87,10 +93,14 @@ export const exportToSvg = async ({
87 93
     null,
88 94
     null,
89 95
   );
90
-  return _exportToSvg(getNonDeletedElements(restoredElements), {
91
-    ...restoredAppState,
92
-    exportPadding,
93
-  });
96
+  return _exportToSvg(
97
+    getNonDeletedElements(restoredElements),
98
+    {
99
+      ...restoredAppState,
100
+      exportPadding,
101
+    },
102
+    files,
103
+  );
94 104
 };
95 105
 
96 106
 export { serializeAsJSON } from "../data/json";

+ 155
- 19
src/renderer/renderElement.ts View File

@@ -5,11 +5,13 @@ import {
5 5
   Arrowhead,
6 6
   NonDeletedExcalidrawElement,
7 7
   ExcalidrawFreeDrawElement,
8
+  ExcalidrawImageElement,
8 9
 } from "../element/types";
9 10
 import {
10 11
   isTextElement,
11 12
   isLinearElement,
12 13
   isFreeDrawElement,
14
+  isInitializedImageElement,
13 15
 } from "../element/typeChecks";
14 16
 import {
15 17
   getDiamondPoints,
@@ -21,22 +23,23 @@ import { Drawable, Options } from "roughjs/bin/core";
21 23
 import { RoughSVG } from "roughjs/bin/svg";
22 24
 import { RoughGenerator } from "roughjs/bin/generator";
23 25
 import { SceneState } from "../scene/types";
24
-import {
25
-  SVG_NS,
26
-  distance,
27
-  getFontString,
28
-  getFontFamilyString,
29
-  isRTL,
30
-} from "../utils";
26
+import { distance, getFontString, getFontFamilyString, isRTL } from "../utils";
31 27
 import { isPathALoop } from "../math";
32 28
 import rough from "roughjs/bin/rough";
33
-import { Zoom } from "../types";
29
+import { AppState, BinaryFiles, Zoom } from "../types";
34 30
 import { getDefaultAppState } from "../appState";
31
+import { MAX_DECIMALS_FOR_SVG_EXPORT, MIME_TYPES, SVG_NS } from "../constants";
35 32
 import { getStroke, StrokeOptions } from "perfect-freehand";
36
-import { MAX_DECIMALS_FOR_SVG_EXPORT } from "../constants";
37 33
 
38 34
 const defaultAppState = getDefaultAppState();
39 35
 
36
+const isPendingImageElement = (
37
+  element: ExcalidrawElement,
38
+  sceneState: SceneState,
39
+) =>
40
+  isInitializedImageElement(element) &&
41
+  !sceneState.imageCache.has(element.fileId);
42
+
40 43
 const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
41 44
 
42 45
 const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
@@ -47,6 +50,7 @@ const getCanvasPadding = (element: ExcalidrawElement) =>
47 50
 export interface ExcalidrawElementWithCanvas {
48 51
   element: ExcalidrawElement | ExcalidrawTextElement;
49 52
   canvas: HTMLCanvasElement;
53
+  theme: SceneState["theme"];
50 54
   canvasZoom: Zoom["value"];
51 55
   canvasOffsetX: number;
52 56
   canvasOffsetY: number;
@@ -55,6 +59,7 @@ export interface ExcalidrawElementWithCanvas {
55 59
 const generateElementCanvas = (
56 60
   element: NonDeletedExcalidrawElement,
57 61
   zoom: Zoom,
62
+  sceneState: SceneState,
58 63
 ): ExcalidrawElementWithCanvas => {
59 64
   const canvas = document.createElement("canvas");
60 65
   const context = canvas.getContext("2d")!;
@@ -111,21 +116,73 @@ const generateElementCanvas = (
111 116
 
112 117
   const rc = rough.canvas(canvas);
113 118
 
114
-  drawElementOnCanvas(element, rc, context);
119
+  if (
120
+    sceneState.theme === "dark" &&
121
+    isInitializedImageElement(element) &&
122
+    !isPendingImageElement(element, sceneState) &&
123
+    sceneState.imageCache.get(element.fileId)?.mimeType !== MIME_TYPES.svg
124
+  ) {
125
+    // using a stronger invert (100% vs our regular 93%) and saturate
126
+    // as a temp hack to make images in dark theme look closer to original
127
+    // color scheme (it's still not quite there and the clors look slightly
128
+    // desaturing/black is not as black, but...)
129
+    context.filter = "invert(100%) hue-rotate(180deg) saturate(1.25)";
130
+  }
131
+
132
+  drawElementOnCanvas(element, rc, context, sceneState);
115 133
   context.restore();
134
+
116 135
   return {
117 136
     element,
118 137
     canvas,
138
+    theme: sceneState.theme,
119 139
     canvasZoom: zoom.value,
120 140
     canvasOffsetX,
121 141
     canvasOffsetY,
122 142
   };
123 143
 };
124 144
 
145
+const IMAGE_PLACEHOLDER_IMG = document.createElement("img");
146
+IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
147
+  `<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg>`,
148
+)}`;
149
+
150
+const IMAGE_ERROR_PLACEHOLDER_IMG = document.createElement("img");
151
+IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
152
+  `<svg viewBox="0 0 668 668" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><path d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48ZM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56ZM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.81709 0 0 .81709 124.825 145.825)"/><path d="M256 8C119.034 8 8 119.033 8 256c0 136.967 111.034 248 248 248s248-111.034 248-248S392.967 8 256 8Zm130.108 117.892c65.448 65.448 70 165.481 20.677 235.637L150.47 105.216c70.204-49.356 170.226-44.735 235.638 20.676ZM125.892 386.108c-65.448-65.448-70-165.481-20.677-235.637L361.53 406.784c-70.203 49.356-170.226 44.736-235.638-20.676Z" style="fill:#888;fill-rule:nonzero" transform="matrix(.30366 0 0 .30366 506.822 60.065)"/></svg>`,
153
+)}`;
154
+
155
+const drawImagePlaceholder = (
156
+  element: ExcalidrawImageElement,
157
+  context: CanvasRenderingContext2D,
158
+  zoomValue: AppState["zoom"]["value"],
159
+) => {
160
+  context.fillStyle = "#E7E7E7";
161
+  context.fillRect(0, 0, element.width, element.height);
162
+
163
+  const imageMinWidthOrHeight = Math.min(element.width, element.height);
164
+
165
+  const size = Math.min(
166
+    imageMinWidthOrHeight,
167
+    Math.min(imageMinWidthOrHeight * 0.4, 100),
168
+  );
169
+
170
+  context.drawImage(
171
+    element.status === "error"
172
+      ? IMAGE_ERROR_PLACEHOLDER_IMG
173
+      : IMAGE_PLACEHOLDER_IMG,
174
+    element.width / 2 - size / 2,
175
+    element.height / 2 - size / 2,
176
+    size,
177
+    size,
178
+  );
179
+};
180
+
125 181
 const drawElementOnCanvas = (
126 182
   element: NonDeletedExcalidrawElement,
127 183
   rc: RoughCanvas,
128 184
   context: CanvasRenderingContext2D,
185
+  sceneState: SceneState,
129 186
 ) => {
130 187
   context.globalAlpha = element.opacity / 100;
131 188
   switch (element.type) {
@@ -160,6 +217,23 @@ const drawElementOnCanvas = (
160 217
       context.restore();
161 218
       break;
162 219
     }
220
+    case "image": {
221
+      const img = isInitializedImageElement(element)
222
+        ? sceneState.imageCache.get(element.fileId)?.image
223
+        : undefined;
224
+      if (img != null && !(img instanceof Promise)) {
225
+        context.drawImage(
226
+          img,
227
+          0 /* hardcoded for the selection box*/,
228
+          0,
229
+          element.width,
230
+          element.height,
231
+        );
232
+      } else {
233
+        drawImagePlaceholder(element, context, sceneState.zoom.value);
234
+      }
235
+      break;
236
+    }
163 237
     default: {
164 238
       if (isTextElement(element)) {
165 239
         const rtl = isRTL(element.text);
@@ -254,6 +328,7 @@ export const generateRoughOptions = (
254 328
   switch (element.type) {
255 329
     case "rectangle":
256 330
     case "diamond":
331
+    case "image":
257 332
     case "ellipse": {
258 333
       options.fillStyle = element.fillStyle;
259 334
       options.fill =
@@ -459,7 +534,8 @@ const generateElementShape = (
459 534
         shape = [];
460 535
         break;
461 536
       }
462
-      case "text": {
537
+      case "text":
538
+      case "image": {
463 539
         // just to ensure we don't regenerate element.canvas on rerenders
464 540
         shape = [];
465 541
         break;
@@ -471,7 +547,7 @@ const generateElementShape = (
471 547
 
472 548
 const generateElementWithCanvas = (
473 549
   element: NonDeletedExcalidrawElement,
474
-  sceneState?: SceneState,
550
+  sceneState: SceneState,
475 551
 ) => {
476 552
   const zoom: Zoom = sceneState ? sceneState.zoom : defaultAppState.zoom;
477 553
   const prevElementWithCanvas = elementWithCanvasCache.get(element);
@@ -479,8 +555,13 @@ const generateElementWithCanvas = (
479 555
     prevElementWithCanvas &&
480 556
     prevElementWithCanvas.canvasZoom !== zoom.value &&
481 557
     !sceneState?.shouldCacheIgnoreZoom;
482
-  if (!prevElementWithCanvas || shouldRegenerateBecauseZoom) {
483
-    const elementWithCanvas = generateElementCanvas(element, zoom);
558
+
559
+  if (
560
+    !prevElementWithCanvas ||
561
+    shouldRegenerateBecauseZoom ||
562
+    prevElementWithCanvas.theme !== sceneState.theme
563
+  ) {
564
+    const elementWithCanvas = generateElementCanvas(element, zoom, sceneState);
484 565
 
485 566
     elementWithCanvasCache.set(element, elementWithCanvas);
486 567
 
@@ -509,10 +590,25 @@ const drawElementFromCanvas = (
509 590
 
510 591
   const cx = ((x1 + x2) / 2 + sceneState.scrollX) * window.devicePixelRatio;
511 592
   const cy = ((y1 + y2) / 2 + sceneState.scrollY) * window.devicePixelRatio;
593
+
594
+  const _isPendingImageElement = isPendingImageElement(element, sceneState);
595
+
596
+  const scaleXFactor =
597
+    "scale" in elementWithCanvas.element && !_isPendingImageElement
598
+      ? elementWithCanvas.element.scale[0]
599
+      : 1;
600
+  const scaleYFactor =
601
+    "scale" in elementWithCanvas.element && !_isPendingImageElement
602
+      ? elementWithCanvas.element.scale[1]
603
+      : 1;
604
+
512 605
   context.save();
513
-  context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
514
-  context.translate(cx, cy);
515
-  context.rotate(element.angle);
606
+  context.scale(
607
+    (1 / window.devicePixelRatio) * scaleXFactor,
608
+    (1 / window.devicePixelRatio) * scaleYFactor,
609
+  );
610
+  context.translate(cx * scaleXFactor, cy * scaleYFactor);
611
+  context.rotate(element.angle * scaleXFactor * scaleYFactor);
516 612
 
517 613
   context.drawImage(
518 614
     elementWithCanvas.canvas!,
@@ -567,7 +663,7 @@ export const renderElement = (
567 663
         context.translate(cx, cy);
568 664
         context.rotate(element.angle);
569 665
         context.translate(-shiftX, -shiftY);
570
-        drawElementOnCanvas(element, rc, context);
666
+        drawElementOnCanvas(element, rc, context, sceneState);
571 667
         context.restore();
572 668
       }
573 669
 
@@ -578,6 +674,7 @@ export const renderElement = (
578 674
     case "ellipse":
579 675
     case "line":
580 676
     case "arrow":
677
+    case "image":
581 678
     case "text": {
582 679
       generateElementShape(element, generator);
583 680
       if (renderOptimizations) {
@@ -596,7 +693,7 @@ export const renderElement = (
596 693
         context.translate(cx, cy);
597 694
         context.rotate(element.angle);
598 695
         context.translate(-shiftX, -shiftY);
599
-        drawElementOnCanvas(element, rc, context);
696
+        drawElementOnCanvas(element, rc, context, sceneState);
600 697
         context.restore();
601 698
       }
602 699
       break;
@@ -628,6 +725,7 @@ export const renderElementToSvg = (
628 725
   element: NonDeletedExcalidrawElement,
629 726
   rsvg: RoughSVG,
630 727
   svgRoot: SVGElement,
728
+  files: BinaryFiles,
631 729
   offsetX?: number,
632 730
   offsetY?: number,
633 731
 ) => {
@@ -723,6 +821,44 @@ export const renderElementToSvg = (
723 821
       svgRoot.appendChild(node);
724 822
       break;
725 823
     }
824
+    case "image": {
825
+      const fileData =
826
+        isInitializedImageElement(element) && files[element.fileId];
827
+      if (fileData) {
828
+        const symbolId = `image-${fileData.id}`;
829
+        let symbol = svgRoot.querySelector(`#${symbolId}`);
830
+        if (!symbol) {
831
+          symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol");
832
+          symbol.id = symbolId;
833
+
834
+          const image = svgRoot.ownerDocument!.createElementNS(SVG_NS, "image");
835
+
836
+          image.setAttribute("width", "100%");
837
+          image.setAttribute("height", "100%");
838
+          image.setAttribute("href", fileData.dataURL);
839
+
840
+          symbol.appendChild(image);
841
+
842
+          svgRoot.prepend(symbol);
843
+        }
844
+
845
+        const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use");
846
+        use.setAttribute("href", `#${symbolId}`);
847
+
848
+        use.setAttribute("width", `${Math.round(element.width)}`);
849
+        use.setAttribute("height", `${Math.round(element.height)}`);
850
+
851
+        use.setAttribute(
852
+          "transform",
853
+          `translate(${offsetX || 0} ${
854
+            offsetY || 0
855
+          }) rotate(${degree} ${cx} ${cy})`,
856
+        );
857
+
858
+        svgRoot.appendChild(use);
859
+      }
860
+      break;
861
+    }
726 862
     default: {
727 863
       if (isTextElement(element)) {
728 864
         const opacity = element.opacity / 100;

+ 9
- 3
src/renderer/renderScene.ts View File

@@ -2,7 +2,7 @@ import { RoughCanvas } from "roughjs/bin/canvas";
2 2
 import { RoughSVG } from "roughjs/bin/svg";
3 3
 import oc from "open-color";
4 4
 
5
-import { AppState, Zoom } from "../types";
5
+import { AppState, BinaryFiles, Zoom } from "../types";
6 6
 import {
7 7
   ExcalidrawElement,
8 8
   NonDeletedExcalidrawElement,
@@ -181,7 +181,7 @@ export const renderScene = (
181 181
   rc: RoughCanvas,
182 182
   canvas: HTMLCanvasElement,
183 183
   sceneState: SceneState,
184
-  // extra options, currently passed by export helper
184
+  // extra options passed to the renderer
185 185
   {
186 186
     renderScrollbars = true,
187 187
     renderSelection = true,
@@ -190,11 +190,15 @@ export const renderScene = (
190 190
     // doesn't guarantee pixel-perfect output.
191 191
     renderOptimizations = false,
192 192
     renderGrid = true,
193
+    /** when exporting the behavior is slightly different (e.g. we can't use
194
+        CSS filters) */
195
+    isExport = false,
193 196
   }: {
194 197
     renderScrollbars?: boolean;
195 198
     renderSelection?: boolean;
196 199
     renderOptimizations?: boolean;
197 200
     renderGrid?: boolean;
201
+    isExport?: boolean;
198 202
   } = {},
199 203
 ) => {
200 204
   if (canvas === null) {
@@ -211,7 +215,7 @@ export const renderScene = (
211 215
   const normalizedCanvasWidth = canvas.width / scale;
212 216
   const normalizedCanvasHeight = canvas.height / scale;
213 217
 
214
-  if (sceneState.exportWithDarkMode) {
218
+  if (isExport && sceneState.theme === "dark") {
215 219
     context.filter = THEME_FILTER;
216 220
   }
217 221
 
@@ -805,6 +809,7 @@ export const renderSceneToSvg = (
805 809
   elements: readonly NonDeletedExcalidrawElement[],
806 810
   rsvg: RoughSVG,
807 811
   svgRoot: SVGElement,
812
+  files: BinaryFiles,
808 813
   {
809 814
     offsetX = 0,
810 815
     offsetY = 0,
@@ -824,6 +829,7 @@ export const renderSceneToSvg = (
824 829
           element,
825 830
           rsvg,
826 831
           svgRoot,
832
+          files,
827 833
           element.x + offsetX,
828 834
           element.y + offsetY,
829 835
         );

+ 2
- 0
src/scene/comparisons.ts View File

@@ -11,6 +11,8 @@ export const hasBackground = (type: string) =>
11 11
   type === "diamond" ||
12 12
   type === "line";
13 13
 
14
+export const hasStrokeColor = (type: string) => type !== "image";
15
+
14 16
 export const hasStrokeWidth = (type: string) =>
15 17
   type === "rectangle" ||
16 18
   type === "ellipse" ||

+ 27
- 9
src/scene/export.ts View File

@@ -2,17 +2,22 @@ import rough from "roughjs/bin/rough";
2 2
 import { NonDeletedExcalidrawElement } from "../element/types";
3 3
 import { getCommonBounds } from "../element/bounds";
4 4
 import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
5
-import { distance, SVG_NS } from "../utils";
6
-import { AppState } from "../types";
7
-import { DEFAULT_EXPORT_PADDING, THEME_FILTER } from "../constants";
5
+import { distance } from "../utils";
6
+import { AppState, BinaryFiles } from "../types";
7
+import { DEFAULT_EXPORT_PADDING, SVG_NS, THEME_FILTER } from "../constants";
8 8
 import { getDefaultAppState } from "../appState";
9 9
 import { serializeAsJSON } from "../data/json";
10
+import {
11
+  getInitializedImageElements,
12
+  updateImageCache,
13
+} from "../element/image";
10 14
 
11 15
 export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
12 16
 
13
-export const exportToCanvas = (
17
+export const exportToCanvas = async (
14 18
   elements: readonly NonDeletedExcalidrawElement[],
15 19
   appState: AppState,
20
+  files: BinaryFiles,
16 21
   {
17 22
     exportBackground,
18 23
     exportPadding = DEFAULT_EXPORT_PADDING,
@@ -36,6 +41,16 @@ export const exportToCanvas = (
36 41
 
37 42
   const { canvas, scale = 1 } = createCanvas(width, height);
38 43
 
44
+  const defaultAppState = getDefaultAppState();
45
+
46
+  const { imageCache } = await updateImageCache({
47
+    imageCache: new Map(),
48
+    fileIds: getInitializedImageElements(elements).map(
49
+      (element) => element.fileId,
50
+    ),
51
+    files,
52
+  });
53
+
39 54
   renderScene(
40 55
     elements,
41 56
     appState,
@@ -45,21 +60,23 @@ export const exportToCanvas = (
45 60
     canvas,
46 61
     {
47 62
       viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
48
-      exportWithDarkMode: appState.exportWithDarkMode,
49 63
       scrollX: -minX + exportPadding,
50 64
       scrollY: -minY + exportPadding,
51
-      zoom: getDefaultAppState().zoom,
65
+      zoom: defaultAppState.zoom,
52 66
       remotePointerViewportCoords: {},
53 67
       remoteSelectedElementIds: {},
54 68
       shouldCacheIgnoreZoom: false,
55 69
       remotePointerUsernames: {},
56 70
       remotePointerUserStates: {},
71
+      theme: appState.exportWithDarkMode ? "dark" : "light",
72
+      imageCache,
57 73
     },
58 74
     {
59 75
       renderScrollbars: false,
60 76
       renderSelection: false,
61
-      renderOptimizations: false,
77
+      renderOptimizations: true,
62 78
       renderGrid: false,
79
+      isExport: true,
63 80
     },
64 81
   );
65 82
 
@@ -76,6 +93,7 @@ export const exportToSvg = async (
76 93
     exportWithDarkMode?: boolean;
77 94
     exportEmbedScene?: boolean;
78 95
   },
96
+  files: BinaryFiles | null,
79 97
 ): Promise<SVGSVGElement> => {
80 98
   const {
81 99
     exportPadding = DEFAULT_EXPORT_PADDING,
@@ -89,7 +107,7 @@ export const exportToSvg = async (
89 107
       metadata = await (
90 108
         await import(/* webpackChunkName: "image" */ "../../src/data/image")
91 109
       ).encodeSvgMetadata({
92
-        text: serializeAsJSON(elements, appState),
110
+        text: serializeAsJSON(elements, appState, files || {}, "local"),
93 111
       });
94 112
     } catch (err) {
95 113
       console.error(err);
@@ -137,7 +155,7 @@ export const exportToSvg = async (
137 155
   }
138 156
 
139 157
   const rsvg = rough.svg(svgRoot);
140
-  renderSceneToSvg(elements, rsvg, svgRoot, {
158
+  renderSceneToSvg(elements, rsvg, svgRoot, files || {}, {
141 159
     offsetX: -minX + exportPadding,
142 160
     offsetY: -minY + exportPadding,
143 161
   });

+ 3
- 2
src/scene/types.ts View File

@@ -1,12 +1,11 @@
1 1
 import { ExcalidrawTextElement } from "../element/types";
2
-import { Zoom } from "../types";
2
+import { AppClassProperties, AppState, Zoom } from "../types";
3 3
 
4 4
 export type SceneState = {
5 5
   scrollX: number;
6 6
   scrollY: number;
7 7
   // null indicates transparent bg
8 8
   viewBackgroundColor: string | null;
9
-  exportWithDarkMode?: boolean;
10 9
   zoom: Zoom;
11 10
   shouldCacheIgnoreZoom: boolean;
12 11
   remotePointerViewportCoords: { [id: string]: { x: number; y: number } };
@@ -14,6 +13,8 @@ export type SceneState = {
14 13
   remoteSelectedElementIds: { [elementId: string]: string[] };
15 14
   remotePointerUsernames: { [id: string]: string };
16 15
   remotePointerUserStates: { [id: string]: string };
16
+  theme: AppState["theme"];
17
+  imageCache: AppClassProperties["imageCache"];
17 18
 };
18 19
 
19 20
 export type SceneScroll = {

+ 17
- 3
src/shapes.tsx View File

@@ -92,15 +92,29 @@ export const SHAPES = [
92 92
     value: "text",
93 93
     key: KEYS.T,
94 94
   },
95
+  {
96
+    icon: (
97
+      // fa-image
98
+      <svg viewBox="0 0 512 512">
99
+        <path
100
+          fill="currentColor"
101
+          d="M464 64H48C21.49 64 0 85.49 0 112v288c0 26.51 21.49 48 48 48h416c26.51 0 48-21.49 48-48V112c0-26.51-21.49-48-48-48zm-6 336H54a6 6 0 0 1-6-6V118a6 6 0 0 1 6-6h404a6 6 0 0 1 6 6v276a6 6 0 0 1-6 6zM128 152c-22.091 0-40 17.909-40 40s17.909 40 40 40 40-17.909 40-40-17.909-40-40-40zM96 352h320v-80l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L192 304l-39.515-39.515c-4.686-4.686-12.284-4.686-16.971 0L96 304v48z"
102
+        ></path>
103
+      </svg>
104
+    ),
105
+    value: "image",
106
+    key: null,
107
+  },
95 108
 ] as const;
96 109
 
97 110
 export const findShapeByKey = (key: string) => {
98 111
   const shape = SHAPES.find((shape, index) => {
99 112
     return (
100 113
       key === (index + 1).toString() ||
101
-      (typeof shape.key === "string"
102
-        ? shape.key === key
103
-        : (shape.key as readonly string[]).includes(key))
114
+      (shape.key &&
115
+        (typeof shape.key === "string"
116
+          ? shape.key === key
117
+          : (shape.key as readonly string[]).includes(key)))
104 118
     );
105 119
   });
106 120
   return shape?.value || null;

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

@@ -49,6 +49,7 @@ Object {
49 49
     "data": null,
50 50
     "shown": false,
51 51
   },
52
+  "pendingImageElement": null,
52 53
   "previousSelectedElementIds": Object {},
53 54
   "resizingElement": null,
54 55
   "scrollX": 0,
@@ -216,6 +217,7 @@ Object {
216 217
     "data": null,
217 218
     "shown": false,
218 219
   },
220
+  "pendingImageElement": null,
219 221
   "previousSelectedElementIds": Object {},
220 222
   "resizingElement": null,
221 223
   "scrollX": 0,
@@ -529,6 +531,7 @@ Object {
529 531
     "data": null,
530 532
     "shown": false,
531 533
   },
534
+  "pendingImageElement": null,
532 535
   "previousSelectedElementIds": Object {},
533 536
   "resizingElement": null,
534 537
   "scrollX": 0,
@@ -842,6 +845,7 @@ Object {
842 845
     "data": null,
843 846
     "shown": false,
844 847
   },
848
+  "pendingImageElement": null,
845 849
   "previousSelectedElementIds": Object {},
846 850
   "resizingElement": null,
847 851
   "scrollX": 0,
@@ -1009,6 +1013,7 @@ Object {
1009 1013
     "data": null,
1010 1014
     "shown": false,
1011 1015
   },
1016
+  "pendingImageElement": null,
1012 1017
   "previousSelectedElementIds": Object {},
1013 1018
   "resizingElement": null,
1014 1019
   "scrollX": 0,
@@ -1209,6 +1214,7 @@ Object {
1209 1214
     "data": null,
1210 1215
     "shown": false,
1211 1216
   },
1217
+  "pendingImageElement": null,
1212 1218
   "previousSelectedElementIds": Object {},
1213 1219
   "resizingElement": null,
1214 1220
   "scrollX": 0,
@@ -1462,6 +1468,7 @@ Object {
1462 1468
     "data": null,
1463 1469
     "shown": false,
1464 1470
   },
1471
+  "pendingImageElement": null,
1465 1472
   "previousSelectedElementIds": Object {
1466 1473
     "id1": true,
1467 1474
   },
@@ -1793,6 +1800,7 @@ Object {
1793 1800
     "data": null,
1794 1801
     "shown": false,
1795 1802
   },
1803
+  "pendingImageElement": null,
1796 1804
   "previousSelectedElementIds": Object {},
1797 1805
   "resizingElement": null,
1798 1806
   "scrollX": 0,
@@ -2526,6 +2534,7 @@ Object {
2526 2534
     "data": null,
2527 2535
     "shown": false,
2528 2536
   },
2537
+  "pendingImageElement": null,
2529 2538
   "previousSelectedElementIds": Object {},
2530 2539
   "resizingElement": null,
2531 2540
   "scrollX": 0,
@@ -2839,6 +2848,7 @@ Object {
2839 2848
     "data": null,
2840 2849
     "shown": false,
2841 2850
   },
2851
+  "pendingImageElement": null,
2842 2852
   "previousSelectedElementIds": Object {},
2843 2853
   "resizingElement": null,
2844 2854
   "scrollX": 0,
@@ -3152,6 +3162,7 @@ Object {
3152 3162
     "data": null,
3153 3163
     "shown": false,
3154 3164
   },
3165
+  "pendingImageElement": null,
3155 3166
   "previousSelectedElementIds": Object {
3156 3167
     "id1": true,
3157 3168
   },
@@ -3539,6 +3550,7 @@ Object {
3539 3550
     "data": null,
3540 3551
     "shown": false,
3541 3552
   },
3553
+  "pendingImageElement": null,
3542 3554
   "previousSelectedElementIds": Object {
3543 3555
     "id0": true,
3544 3556
     "id2": true,
@@ -3798,6 +3810,7 @@ Object {
3798 3810
     "data": null,
3799 3811
     "shown": false,
3800 3812
   },
3813
+  "pendingImageElement": null,
3801 3814
   "previousSelectedElementIds": Object {
3802 3815
     "id0": true,
3803 3816
     "id2": true,
@@ -4132,6 +4145,7 @@ Object {
4132 4145
     "data": null,
4133 4146
     "shown": false,
4134 4147
   },
4148
+  "pendingImageElement": null,
4135 4149
   "previousSelectedElementIds": Object {},
4136 4150
   "resizingElement": null,
4137 4151
   "scrollX": 0,
@@ -4234,6 +4248,7 @@ Object {
4234 4248
     "data": null,
4235 4249
     "shown": false,
4236 4250
   },
4251
+  "pendingImageElement": null,
4237 4252
   "previousSelectedElementIds": Object {},
4238 4253
   "resizingElement": null,
4239 4254
   "scrollX": 0,
@@ -4314,6 +4329,7 @@ Object {
4314 4329
     "data": null,
4315 4330
     "shown": false,
4316 4331
   },
4332
+  "pendingImageElement": null,
4317 4333
   "previousSelectedElementIds": Object {},
4318 4334
   "resizingElement": null,
4319 4335
   "scrollX": 0,

+ 52
- 0
src/tests/__snapshots__/regressionTests.test.tsx.snap View File

@@ -49,6 +49,7 @@ Object {
49 49
     "data": null,
50 50
     "shown": false,
51 51
   },
52
+  "pendingImageElement": null,
52 53
   "previousSelectedElementIds": Object {
53 54
     "id0": true,
54 55
     "id1": true,
@@ -518,6 +519,7 @@ Object {
518 519
     "data": null,
519 520
     "shown": false,
520 521
   },
522
+  "pendingImageElement": null,
521 523
   "previousSelectedElementIds": Object {
522 524
     "id0": true,
523 525
     "id1": true,
@@ -993,6 +995,7 @@ Object {
993 995
     "data": null,
994 996
     "shown": false,
995 997
   },
998
+  "pendingImageElement": null,
996 999
   "previousSelectedElementIds": Object {},
997 1000
   "resizingElement": null,
998 1001
   "scrollX": 0,
@@ -1783,6 +1786,7 @@ Object {
1783 1786
     "data": null,
1784 1787
     "shown": false,
1785 1788
   },
1789
+  "pendingImageElement": null,
1786 1790
   "previousSelectedElementIds": Object {
1787 1791
     "id0": true,
1788 1792
   },
@@ -1991,6 +1995,7 @@ Object {
1991 1995
     "data": null,
1992 1996
     "shown": false,
1993 1997
   },
1998
+  "pendingImageElement": null,
1994 1999
   "previousSelectedElementIds": Object {
1995 2000
     "id0": true,
1996 2001
     "id3": true,
@@ -2457,6 +2462,7 @@ Object {
2457 2462
     "data": null,
2458 2463
     "shown": false,
2459 2464
   },
2465
+  "pendingImageElement": null,
2460 2466
   "previousSelectedElementIds": Object {
2461 2467
     "id0": true,
2462 2468
   },
@@ -2714,6 +2720,7 @@ Object {
2714 2720
     "data": null,
2715 2721
     "shown": false,
2716 2722
   },
2723
+  "pendingImageElement": null,
2717 2724
   "previousSelectedElementIds": Object {},
2718 2725
   "resizingElement": null,
2719 2726
   "scrollX": 0,
@@ -2881,6 +2888,7 @@ Object {
2881 2888
     "data": null,
2882 2889
     "shown": false,
2883 2890
   },
2891
+  "pendingImageElement": null,
2884 2892
   "previousSelectedElementIds": Object {
2885 2893
     "id2": true,
2886 2894
   },
@@ -3330,6 +3338,7 @@ Object {
3330 3338
     "data": null,
3331 3339
     "shown": false,
3332 3340
   },
3341
+  "pendingImageElement": null,
3333 3342
   "previousSelectedElementIds": Object {},
3334 3343
   "resizingElement": null,
3335 3344
   "scrollX": 0,
@@ -3571,6 +3580,7 @@ Object {
3571 3580
     "data": null,
3572 3581
     "shown": false,
3573 3582
   },
3583
+  "pendingImageElement": null,
3574 3584
   "previousSelectedElementIds": Object {
3575 3585
     "id0": true,
3576 3586
   },
@@ -3779,6 +3789,7 @@ Object {
3779 3789
     "data": null,
3780 3790
     "shown": false,
3781 3791
   },
3792
+  "pendingImageElement": null,
3782 3793
   "previousSelectedElementIds": Object {
3783 3794
     "id0": true,
3784 3795
     "id1": true,
@@ -4028,6 +4039,7 @@ Object {
4028 4039
     "data": null,
4029 4040
     "shown": false,
4030 4041
   },
4042
+  "pendingImageElement": null,
4031 4043
   "previousSelectedElementIds": Object {
4032 4044
     "id1": true,
4033 4045
   },
@@ -4284,6 +4296,7 @@ Object {
4284 4296
     "data": null,
4285 4297
     "shown": false,
4286 4298
   },
4299
+  "pendingImageElement": null,
4287 4300
   "previousSelectedElementIds": Object {
4288 4301
     "id2": true,
4289 4302
   },
@@ -4672,6 +4685,7 @@ Object {
4672 4685
     "data": null,
4673 4686
     "shown": false,
4674 4687
   },
4688
+  "pendingImageElement": null,
4675 4689
   "previousSelectedElementIds": Object {
4676 4690
     "id0": true,
4677 4691
     "id1": true,
@@ -4971,6 +4985,7 @@ Object {
4971 4985
     "data": null,
4972 4986
     "shown": false,
4973 4987
   },
4988
+  "pendingImageElement": null,
4974 4989
   "previousSelectedElementIds": Object {
4975 4990
     "id0": true,
4976 4991
     "id1": true,
@@ -5248,6 +5263,7 @@ Object {
5248 5263
     "data": null,
5249 5264
     "shown": false,
5250 5265
   },
5266
+  "pendingImageElement": null,
5251 5267
   "previousSelectedElementIds": Object {
5252 5268
     "id0": true,
5253 5269
   },
@@ -5459,6 +5475,7 @@ Object {
5459 5475
     "data": null,
5460 5476
     "shown": false,
5461 5477
   },
5478
+  "pendingImageElement": null,
5462 5479
   "previousSelectedElementIds": Object {
5463 5480
     "id0": true,
5464 5481
   },
@@ -5626,6 +5643,7 @@ Object {
5626 5643
     "data": null,
5627 5644
     "shown": false,
5628 5645
   },
5646
+  "pendingImageElement": null,
5629 5647
   "previousSelectedElementIds": Object {},
5630 5648
   "resizingElement": null,
5631 5649
   "scrollX": 0,
@@ -6087,6 +6105,7 @@ Object {
6087 6105
     "data": null,
6088 6106
     "shown": false,
6089 6107
   },
6108
+  "pendingImageElement": null,
6090 6109
   "previousSelectedElementIds": Object {
6091 6110
     "id0": true,
6092 6111
     "id1": true,
@@ -6410,6 +6429,7 @@ Object {
6410 6429
     "data": null,
6411 6430
     "shown": false,
6412 6431
   },
6432
+  "pendingImageElement": null,
6413 6433
   "previousSelectedElementIds": Object {},
6414 6434
   "resizingElement": null,
6415 6435
   "scrollX": 0,
@@ -8474,6 +8494,7 @@ Object {
8474 8494
     "data": null,
8475 8495
     "shown": false,
8476 8496
   },
8497
+  "pendingImageElement": null,
8477 8498
   "previousSelectedElementIds": Object {
8478 8499
     "id0": true,
8479 8500
     "id2": true,
@@ -8841,6 +8862,7 @@ Object {
8841 8862
     "data": null,
8842 8863
     "shown": false,
8843 8864
   },
8865
+  "pendingImageElement": null,
8844 8866
   "previousSelectedElementIds": Object {
8845 8867
     "id0": true,
8846 8868
     "id2": true,
@@ -9098,6 +9120,7 @@ Object {
9098 9120
     "data": null,
9099 9121
     "shown": false,
9100 9122
   },
9123
+  "pendingImageElement": null,
9101 9124
   "previousSelectedElementIds": Object {
9102 9125
     "id0": true,
9103 9126
     "id2": true,
@@ -9319,6 +9342,7 @@ Object {
9319 9342
     "data": null,
9320 9343
     "shown": false,
9321 9344
   },
9345
+  "pendingImageElement": null,
9322 9346
   "previousSelectedElementIds": Object {
9323 9347
     "id0": true,
9324 9348
     "id2": true,
@@ -9603,6 +9627,7 @@ Object {
9603 9627
     "data": null,
9604 9628
     "shown": false,
9605 9629
   },
9630
+  "pendingImageElement": null,
9606 9631
   "previousSelectedElementIds": Object {},
9607 9632
   "resizingElement": null,
9608 9633
   "scrollX": 0,
@@ -9770,6 +9795,7 @@ Object {
9770 9795
     "data": null,
9771 9796
     "shown": false,
9772 9797
   },
9798
+  "pendingImageElement": null,
9773 9799
   "previousSelectedElementIds": Object {},
9774 9800
   "resizingElement": null,
9775 9801
   "scrollX": 0,
@@ -9937,6 +9963,7 @@ Object {
9937 9963
     "data": null,
9938 9964
     "shown": false,
9939 9965
   },
9966
+  "pendingImageElement": null,
9940 9967
   "previousSelectedElementIds": Object {},
9941 9968
   "resizingElement": null,
9942 9969
   "scrollX": 0,
@@ -10104,6 +10131,7 @@ Object {
10104 10131
     "data": null,
10105 10132
     "shown": false,
10106 10133
   },
10134
+  "pendingImageElement": null,
10107 10135
   "previousSelectedElementIds": Object {},
10108 10136
   "resizingElement": null,
10109 10137
   "scrollX": 0,
@@ -10301,6 +10329,7 @@ Object {
10301 10329
     "data": null,
10302 10330
     "shown": false,
10303 10331
   },
10332
+  "pendingImageElement": null,
10304 10333
   "previousSelectedElementIds": Object {},
10305 10334
   "resizingElement": null,
10306 10335
   "scrollX": 0,
@@ -10498,6 +10527,7 @@ Object {
10498 10527
     "data": null,
10499 10528
     "shown": false,
10500 10529
   },
10530
+  "pendingImageElement": null,
10501 10531
   "previousSelectedElementIds": Object {},
10502 10532
   "resizingElement": null,
10503 10533
   "scrollX": 0,
@@ -10713,6 +10743,7 @@ Object {
10713 10743
     "data": null,
10714 10744
     "shown": false,
10715 10745
   },
10746
+  "pendingImageElement": null,
10716 10747
   "previousSelectedElementIds": Object {},
10717 10748
   "resizingElement": null,
10718 10749
   "scrollX": 0,
@@ -10910,6 +10941,7 @@ Object {
10910 10941
     "data": null,
10911 10942
     "shown": false,
10912 10943
   },
10944
+  "pendingImageElement": null,
10913 10945
   "previousSelectedElementIds": Object {},
10914 10946
   "resizingElement": null,
10915 10947
   "scrollX": 0,
@@ -11077,6 +11109,7 @@ Object {
11077 11109
     "data": null,
11078 11110
     "shown": false,
11079 11111
   },
11112
+  "pendingImageElement": null,
11080 11113
   "previousSelectedElementIds": Object {},
11081 11114
   "resizingElement": null,
11082 11115
   "scrollX": 0,
@@ -11244,6 +11277,7 @@ Object {
11244 11277
     "data": null,
11245 11278
     "shown": false,
11246 11279
   },
11280
+  "pendingImageElement": null,
11247 11281
   "previousSelectedElementIds": Object {},
11248 11282
   "resizingElement": null,
11249 11283
   "scrollX": 0,
@@ -11441,6 +11475,7 @@ Object {
11441 11475
     "data": null,
11442 11476
     "shown": false,
11443 11477
   },
11478
+  "pendingImageElement": null,
11444 11479
   "previousSelectedElementIds": Object {},
11445 11480
   "resizingElement": null,
11446 11481
   "scrollX": 0,
@@ -11608,6 +11643,7 @@ Object {
11608 11643
     "data": null,
11609 11644
     "shown": false,
11610 11645
   },
11646
+  "pendingImageElement": null,
11611 11647
   "previousSelectedElementIds": Object {},
11612 11648
   "resizingElement": null,
11613 11649
   "scrollX": 0,
@@ -11823,6 +11859,7 @@ Object {
11823 11859
     "data": null,
11824 11860
     "shown": false,
11825 11861
   },
11862
+  "pendingImageElement": null,
11826 11863
   "previousSelectedElementIds": Object {
11827 11864
     "id0": true,
11828 11865
     "id1": true,
@@ -12550,6 +12587,7 @@ Object {
12550 12587
     "data": null,
12551 12588
     "shown": false,
12552 12589
   },
12590
+  "pendingImageElement": null,
12553 12591
   "previousSelectedElementIds": Object {
12554 12592
     "id0": true,
12555 12593
     "id3": true,
@@ -12807,6 +12845,7 @@ Object {
12807 12845
     "data": null,
12808 12846
     "shown": false,
12809 12847
   },
12848
+  "pendingImageElement": null,
12810 12849
   "previousSelectedElementIds": Object {},
12811 12850
   "resizingElement": null,
12812 12851
   "scrollX": -5.416666666666667,
@@ -12911,6 +12950,7 @@ Object {
12911 12950
     "data": null,
12912 12951
     "shown": false,
12913 12952
   },
12953
+  "pendingImageElement": null,
12914 12954
   "previousSelectedElementIds": Object {},
12915 12955
   "resizingElement": null,
12916 12956
   "scrollX": 0,
@@ -13013,6 +13053,7 @@ Object {
13013 13053
     "data": null,
13014 13054
     "shown": false,
13015 13055
   },
13056
+  "pendingImageElement": null,
13016 13057
   "previousSelectedElementIds": Object {
13017 13058
     "id0": true,
13018 13059
   },
@@ -13183,6 +13224,7 @@ Object {
13183 13224
     "data": null,
13184 13225
     "shown": false,
13185 13226
   },
13227
+  "pendingImageElement": null,
13186 13228
   "previousSelectedElementIds": Object {
13187 13229
     "id0": true,
13188 13230
     "id1": true,
@@ -13509,6 +13551,7 @@ Object {
13509 13551
     "data": null,
13510 13552
     "shown": false,
13511 13553
   },
13554
+  "pendingImageElement": null,
13512 13555
   "previousSelectedElementIds": Object {},
13513 13556
   "resizingElement": null,
13514 13557
   "scrollX": 0,
@@ -13713,6 +13756,7 @@ Object {
13713 13756
     "data": null,
13714 13757
     "shown": false,
13715 13758
   },
13759
+  "pendingImageElement": null,
13716 13760
   "previousSelectedElementIds": Object {
13717 13761
     "id0": true,
13718 13762
     "id1": true,
@@ -14549,6 +14593,7 @@ Object {
14549 14593
     "data": null,
14550 14594
     "shown": false,
14551 14595
   },
14596
+  "pendingImageElement": null,
14552 14597
   "previousSelectedElementIds": Object {},
14553 14598
   "resizingElement": null,
14554 14599
   "scrollX": 60,
@@ -14651,6 +14696,7 @@ Object {
14651 14696
     "data": null,
14652 14697
     "shown": false,
14653 14698
   },
14699
+  "pendingImageElement": null,
14654 14700
   "previousSelectedElementIds": Object {
14655 14701
     "id0": true,
14656 14702
   },
@@ -15420,6 +15466,7 @@ Object {
15420 15466
     "data": null,
15421 15467
     "shown": false,
15422 15468
   },
15469
+  "pendingImageElement": null,
15423 15470
   "previousSelectedElementIds": Object {
15424 15471
     "id1": true,
15425 15472
     "id2": true,
@@ -15830,6 +15877,7 @@ Object {
15830 15877
     "data": null,
15831 15878
     "shown": false,
15832 15879
   },
15880
+  "pendingImageElement": null,
15833 15881
   "previousSelectedElementIds": Object {
15834 15882
     "id1": true,
15835 15883
   },
@@ -16107,6 +16155,7 @@ Object {
16107 16155
     "data": null,
16108 16156
     "shown": false,
16109 16157
   },
16158
+  "pendingImageElement": null,
16110 16159
   "previousSelectedElementIds": Object {},
16111 16160
   "resizingElement": null,
16112 16161
   "scrollX": 11.046099290780141,
@@ -16211,6 +16260,7 @@ Object {
16211 16260
     "data": null,
16212 16261
     "shown": false,
16213 16262
   },
16263
+  "pendingImageElement": null,
16214 16264
   "previousSelectedElementIds": Object {},
16215 16265
   "resizingElement": null,
16216 16266
   "scrollX": 0,
@@ -16715,6 +16765,7 @@ Object {
16715 16765
     "data": null,
16716 16766
     "shown": false,
16717 16767
   },
16768
+  "pendingImageElement": null,
16718 16769
   "previousSelectedElementIds": Object {},
16719 16770
   "resizingElement": null,
16720 16771
   "scrollX": 0,
@@ -16817,6 +16868,7 @@ Object {
16817 16868
     "data": null,
16818 16869
     "shown": false,
16819 16870
   },
16871
+  "pendingImageElement": null,
16820 16872
   "previousSelectedElementIds": Object {},
16821 16873
   "resizingElement": null,
16822 16874
   "scrollX": 0,

+ 2
- 2
src/tests/appState.test.tsx View File

@@ -2,7 +2,7 @@ import { render, waitFor } from "./test-utils";
2 2
 import ExcalidrawApp from "../excalidraw-app";
3 3
 import { API } from "./helpers/api";
4 4
 import { getDefaultAppState } from "../appState";
5
-import { EXPORT_DATA_TYPES } from "../constants";
5
+import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
6 6
 
7 7
 const { h } = window;
8 8
 
@@ -36,7 +36,7 @@ describe("appState", () => {
36 36
             elements: [API.createElement({ type: "rectangle", id: "A" })],
37 37
           }),
38 38
         ],
39
-        { type: "application/json" },
39
+        { type: MIME_TYPES.json },
40 40
       ),
41 41
     );
42 42
 

+ 10
- 0
src/tests/collab.test.tsx View File

@@ -19,11 +19,21 @@ jest.mock("../excalidraw-app/data/firebase.ts", () => {
19 19
   const loadFromFirebase = async () => null;
20 20
   const saveToFirebase = () => {};
21 21
   const isSavedToFirebase = () => true;
22
+  const loadFilesFromFirebase = async () => ({
23
+    loadedFiles: [],
24
+    erroredFiles: [],
25
+  });
26
+  const saveFilesToFirebase = async () => ({
27
+    savedFiles: new Map(),
28
+    erroredFiles: new Map(),
29
+  });
22 30
 
23 31
   return {
24 32
     loadFromFirebase,
25 33
     saveToFirebase,
26 34
     isSavedToFirebase,
35
+    loadFilesFromFirebase,
36
+    saveFilesToFirebase,
27 37
   };
28 38
 });
29 39
 

+ 2
- 2
src/tests/export.test.tsx View File

@@ -45,7 +45,7 @@ describe("export", () => {
45 45
     const pngBlob = await API.loadFile("./fixtures/smiley.png");
46 46
     const pngBlobEmbedded = await encodePngMetadata({
47 47
       blob: pngBlob,
48
-      metadata: serializeAsJSON(testElements, h.state),
48
+      metadata: serializeAsJSON(testElements, h.state, {}, "local"),
49 49
     });
50 50
     API.drop(pngBlobEmbedded);
51 51
 
@@ -58,7 +58,7 @@ describe("export", () => {
58 58
 
59 59
   it("test encoding/decoding scene for SVG export", async () => {
60 60
     const encoded = await encodeSvgMetadata({
61
-      text: serializeAsJSON(testElements, h.state),
61
+      text: serializeAsJSON(testElements, h.state, {}, "local"),
62 62
     });
63 63
     const decoded = JSON.parse(await decodeSvgMetadata({ svg: encoded }));
64 64
     expect(decoded.elements).toEqual([

+ 1
- 0
src/tests/fixtures/diagramFixture.ts View File

@@ -13,6 +13,7 @@ export const diagramFixture = {
13 13
     viewBackgroundColor: "#ffffff",
14 14
     gridSize: null,
15 15
   },
16
+  files: {},
16 17
 };
17 18
 
18 19
 export const diagramFactory = ({

+ 2
- 2
src/tests/history.test.tsx View File

@@ -5,7 +5,7 @@ import { API } from "./helpers/api";
5 5
 import { getDefaultAppState } from "../appState";
6 6
 import { waitFor } from "@testing-library/react";
7 7
 import { createUndoAction, createRedoAction } from "../actions/actionHistory";
8
-import { EXPORT_DATA_TYPES } from "../constants";
8
+import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
9 9
 
10 10
 const { h } = window;
11 11
 
@@ -86,7 +86,7 @@ describe("history", () => {
86 86
             elements: [API.createElement({ type: "rectangle", id: "B" })],
87 87
           }),
88 88
         ],
89
-        { type: "application/json" },
89
+        { type: MIME_TYPES.json },
90 90
       ),
91 91
     );
92 92
 

+ 1
- 0
src/tests/packages/__snapshots__/utils.test.ts.snap View File

@@ -47,6 +47,7 @@ Object {
47 47
     "data": null,
48 48
     "shown": false,
49 49
   },
50
+  "pendingImageElement": null,
50 51
   "previousSelectedElementIds": Object {},
51 52
   "resizingElement": null,
52 53
   "scrollX": 0,

+ 10
- 8
src/tests/packages/utils.test.ts View File

@@ -1,6 +1,7 @@
1 1
 import * as utils from "../../packages/utils";
2 2
 import { diagramFactory } from "../fixtures/diagramFixture";
3 3
 import * as mockedSceneExportUtils from "../../scene/export";
4
+import { MIME_TYPES } from "../../constants";
4 5
 
5 6
 jest.mock("../../scene/export", () => ({
6 7
   __esmodule: true,
@@ -11,8 +12,8 @@ jest.mock("../../scene/export", () => ({
11 12
 describe("exportToCanvas", () => {
12 13
   const EXPORT_PADDING = 10;
13 14
 
14
-  it("with default arguments", () => {
15
-    const canvas = utils.exportToCanvas({
15
+  it("with default arguments", async () => {
16
+    const canvas = await utils.exportToCanvas({
16 17
       ...diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
17 18
     });
18 19
 
@@ -20,8 +21,8 @@ describe("exportToCanvas", () => {
20 21
     expect(canvas.height).toBe(100 + 2 * EXPORT_PADDING);
21 22
   });
22 23
 
23
-  it("when custom width and height", () => {
24
-    const canvas = utils.exportToCanvas({
24
+  it("when custom width and height", async () => {
25
+    const canvas = await utils.exportToCanvas({
25 26
       ...diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
26 27
       getDimensions: () => ({ width: 200, height: 200, scale: 1 }),
27 28
     });
@@ -39,16 +40,17 @@ describe("exportToBlob", () => {
39 40
       const blob = await utils.exportToBlob({
40 41
         ...diagramFactory(),
41 42
         getDimensions: (width, height) => ({ width, height, scale: 1 }),
43
+        // testing typo in MIME type (jpg → jpeg)
42 44
         mimeType: "image/jpg",
43 45
       });
44
-      expect(blob?.type).toBe("image/jpeg");
46
+      expect(blob?.type).toBe(MIME_TYPES.jpg);
45 47
     });
46 48
 
47 49
     it("should default to image/png", async () => {
48 50
       const blob = await utils.exportToBlob({
49 51
         ...diagramFactory(),
50 52
       });
51
-      expect(blob?.type).toBe("image/png");
53
+      expect(blob?.type).toBe(MIME_TYPES.png);
52 54
     });
53 55
 
54 56
     it("should warn when using quality with image/png", async () => {
@@ -58,12 +60,12 @@ describe("exportToBlob", () => {
58 60
 
59 61
       await utils.exportToBlob({
60 62
         ...diagramFactory(),
61
-        mimeType: "image/png",
63
+        mimeType: MIME_TYPES.png,
62 64
         quality: 1,
63 65
       });
64 66
 
65 67
       expect(consoleSpy).toHaveBeenCalledWith(
66
-        '"quality" will be ignored for "image/png" mimeType',
68
+        `"quality" will be ignored for "${MIME_TYPES.png}" mimeType`,
67 69
       );
68 70
     });
69 71
   });

+ 1
- 1
src/tests/scene/__snapshots__/export.test.ts.snap View File

@@ -74,7 +74,7 @@ exports[`exportToSvg with default arguments 1`] = `
74 74
 exports[`exportToSvg with exportEmbedScene 1`] = `
75 75
 "
76 76
   <!-- svg-source:excalidraw -->
77
-  <!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1SQW7CMFx1MDAxMLzzisi9XCKRpIFQbrRUVaWqPXBAatWDiTexhbGD7Vx1MDAwMFx1MDAxMeLvtVx1MDAxZEjaqP1BfbC0szO76/WcXHUwMDA2QYBMXVx1MDAwMppcdTAwMDVcYo5cdTAwMTnmjCh8QEOH70FpJoVNxT7WslKZZ1JjytloxKVcdTAwMTVQqU3DXHUwMDA3XHUwMDBlW1x1MDAxMEZbxoeNg+Dkb5thxKn2K7V7m+dcdTAwMWImSLzLtunLYv707qWedLScJErauHaNb9M2PjBiqMWiMGwxXG6soKZcdTAwMDdiUXA3Zodoo+RcdTAwMDZcdTAwMWUkl8pccnJcdTAwMTP607Ve42xTKFlcdNJxojHG67zj5Izzpal5s1x1MDAwMJzRSlx1MDAwMep1WF1H7OGtTku74E5lW1x1MDAxNlSA1j80ssRcdTAwMTkzde9Vbr7ymfjtfvbrU6zKS1x1MDAxZKRd8G0yXHUwMDAw4ksl0WSc3oXTNtP9b1x1MDAxNId99FVcbv/XUTSdhmFcdTAwMTKnk5bB9MJ+tfFlc8w1dHt0K3xsbNCMKirO2/TVaIThrVx1MDAxNFx1MDAwNHn8PPz3yr9X/vRcbnDOSlxyXHUwMDE3r9jbv1x1MDAwN+GyXFxcdTAwMWFsXHUwMDFjpXFcdTAwMGXaMzjc//I3uT9Of1x1MDAxZZy/XHUwMDAw6cxEtiJ9<!-- payload-end -->
77
+  <!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1SQW7CMFx1MDAxMLzzisi9XCKRpIFQbrRUVaWqPXBAatWDiTexhbGD7Vx1MDAwMFx1MDAxMeLvtVx1MDAxZEjaqP1BfbC045nd9e6cXHUwMDA2QYBMXVx1MDAwMppcdTAwMDVcYo5cdTAwMTnmjCh8QEOH70FpJoV9in2sZaUyz6TGlLPRiEsroFKbhlx1MDAwZlx1MDAxY7YgjLaMXHUwMDBmXHUwMDFiXHUwMDA3wcnf9oVcdTAwMTGn2q/U7m2eb5gg8S7bpi+L+dO7l3rS0XKSKGnj2lx1MDAxNb5N2/jAiKFcdTAwMTaLwrDFKLCCmlx1MDAxZYhFwV2bXHUwMDFkoo2SXHUwMDFieJBcXCrXyE3oT1d6jbNNoWQlSMeJxlx1MDAxOK/zjpMzzpem5s1cdTAwMDBwRitcdTAwMDWoV2F1bbGHtzot7YA7lS1ZUFx1MDAwMVr/0MhcdTAwMTJnzNS9X7n+ymfip/vZz0+xKi95kHbBt85cdTAwMDCIT5VEk3F6XHUwMDE3TtuXbr9RXHUwMDFj9tFXKfyuo2g6XHLDJE4nLYPphV218WlzzDV0c3QjfGxs0LQqKs7b56vRXGLDWylcYvL4efjvlX+v/OlcdTAwMTXgnJVcdTAwMWEuXrG3/1x1MDAwZsJluTTYOErjXHUwMDFjtGdwuP9lN7k/Tu+d5nZcdTAwMDOu2uk8OH9cdTAwMDFcZrNI1SJ9<!-- payload-end -->
78 78
   <defs>
79 79
     <style>
80 80
       @font-face {

+ 48
- 23
src/tests/scene/export.test.ts View File

@@ -13,10 +13,15 @@ describe("exportToSvg", () => {
13 13
   const DEFAULT_OPTIONS = {
14 14
     exportBackground: false,
15 15
     viewBackgroundColor: "#ffffff",
16
+    files: {},
16 17
   };
17 18
 
18 19
   it("with default arguments", async () => {
19
-    const svgElement = await exportUtils.exportToSvg(ELEMENTS, DEFAULT_OPTIONS);
20
+    const svgElement = await exportUtils.exportToSvg(
21
+      ELEMENTS,
22
+      DEFAULT_OPTIONS,
23
+      null,
24
+    );
20 25
 
21 26
     expect(svgElement).toMatchSnapshot();
22 27
   });
@@ -24,11 +29,15 @@ describe("exportToSvg", () => {
24 29
   it("with background color", async () => {
25 30
     const BACKGROUND_COLOR = "#abcdef";
26 31
 
27
-    const svgElement = await exportUtils.exportToSvg(ELEMENTS, {
28
-      ...DEFAULT_OPTIONS,
29
-      exportBackground: true,
30
-      viewBackgroundColor: BACKGROUND_COLOR,
31
-    });
32
+    const svgElement = await exportUtils.exportToSvg(
33
+      ELEMENTS,
34
+      {
35
+        ...DEFAULT_OPTIONS,
36
+        exportBackground: true,
37
+        viewBackgroundColor: BACKGROUND_COLOR,
38
+      },
39
+      null,
40
+    );
32 41
 
33 42
     expect(svgElement.querySelector("rect")).toHaveAttribute(
34 43
       "fill",
@@ -37,10 +46,14 @@ describe("exportToSvg", () => {
37 46
   });
38 47
 
39 48
   it("with dark mode", async () => {
40
-    const svgElement = await exportUtils.exportToSvg(ELEMENTS, {
41
-      ...DEFAULT_OPTIONS,
42
-      exportWithDarkMode: true,
43
-    });
49
+    const svgElement = await exportUtils.exportToSvg(
50
+      ELEMENTS,
51
+      {
52
+        ...DEFAULT_OPTIONS,
53
+        exportWithDarkMode: true,
54
+      },
55
+      null,
56
+    );
44 57
 
45 58
     expect(svgElement.getAttribute("filter")).toMatchInlineSnapshot(
46 59
       `"themeFilter"`,
@@ -48,10 +61,14 @@ describe("exportToSvg", () => {
48 61
   });
49 62
 
50 63
   it("with exportPadding", async () => {
51
-    const svgElement = await exportUtils.exportToSvg(ELEMENTS, {
52
-      ...DEFAULT_OPTIONS,
53
-      exportPadding: 0,
54
-    });
64
+    const svgElement = await exportUtils.exportToSvg(
65
+      ELEMENTS,
66
+      {
67
+        ...DEFAULT_OPTIONS,
68
+        exportPadding: 0,
69
+      },
70
+      null,
71
+    );
55 72
 
56 73
     expect(svgElement).toHaveAttribute("height", ELEMENT_HEIGHT.toString());
57 74
     expect(svgElement).toHaveAttribute("width", ELEMENT_WIDTH.toString());
@@ -64,11 +81,15 @@ describe("exportToSvg", () => {
64 81
   it("with scale", async () => {
65 82
     const SCALE = 2;
66 83
 
67
-    const svgElement = await exportUtils.exportToSvg(ELEMENTS, {
68
-      ...DEFAULT_OPTIONS,
69
-      exportPadding: 0,
70
-      exportScale: SCALE,
71
-    });
84
+    const svgElement = await exportUtils.exportToSvg(
85
+      ELEMENTS,
86
+      {
87
+        ...DEFAULT_OPTIONS,
88
+        exportPadding: 0,
89
+        exportScale: SCALE,
90
+      },
91
+      null,
92
+    );
72 93
 
73 94
     expect(svgElement).toHaveAttribute(
74 95
       "height",
@@ -81,10 +102,14 @@ describe("exportToSvg", () => {
81 102
   });
82 103
 
83 104
   it("with exportEmbedScene", async () => {
84
-    const svgElement = await exportUtils.exportToSvg(ELEMENTS, {
85
-      ...DEFAULT_OPTIONS,
86
-      exportEmbedScene: true,
87
-    });
105
+    const svgElement = await exportUtils.exportToSvg(
106
+      ELEMENTS,
107
+      {
108
+        ...DEFAULT_OPTIONS,
109
+        exportEmbedScene: true,
110
+      },
111
+      null,
112
+    );
88 113
     expect(svgElement.innerHTML).toMatchSnapshot();
89 114
   });
90 115
 });

+ 2
- 0
src/tests/test-utils.ts View File

@@ -16,6 +16,8 @@ import { SceneData } from "../types";
16 16
 import { getSelectedElements } from "../scene/selection";
17 17
 import { ExcalidrawElement } from "../element/types";
18 18
 
19
+require("fake-indexeddb/auto");
20
+
19 21
 const customQueries = {
20 22
   ...queries,
21 23
   ...toolQueries,

+ 46
- 1
src/types.ts View File

@@ -10,6 +10,8 @@ import {
10 10
   Arrowhead,
11 11
   ChartType,
12 12
   FontFamilyValues,
13
+  FileId,
14
+  ExcalidrawImageElement,
13 15
   Theme,
14 16
 } from "./element/types";
15 17
 import { SHAPES } from "./shapes";
@@ -24,7 +26,9 @@ import { Language } from "./i18n";
24 26
 import { ClipboardData } from "./clipboard";
25 27
 import { isOverScrollBars } from "./scene";
26 28
 import { MaybeTransformHandleType } from "./element/transformHandles";
27
-import { FileSystemHandle } from "./data/filesystem";
29
+import Library from "./data/library";
30
+import type { FileSystemHandle } from "./data/filesystem";
31
+import type { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
28 32
 
29 33
 export type Point = Readonly<RoughPoint>;
30 34
 
@@ -43,6 +47,22 @@ export type Collaborator = {
43 47
   };
44 48
 };
45 49
 
50
+export type DataURL = string & { _brand: "DataURL" };
51
+
52
+export type BinaryFileData = {
53
+  mimeType:
54
+    | typeof ALLOWED_IMAGE_MIME_TYPES[number]
55
+    // future user or unknown file type
56
+    | typeof MIME_TYPES.binary;
57
+  id: FileId;
58
+  dataURL: DataURL;
59
+  created: number;
60
+};
61
+
62
+export type BinaryFileMetadata = Omit<BinaryFileData, "dataURL">;
63
+
64
+export type BinaryFiles = Record<ExcalidrawElement["id"], BinaryFileData>;
65
+
46 66
 export type AppState = {
47 67
   isLoading: boolean;
48 68
   errorMessage: string | null;
@@ -127,6 +147,8 @@ export type AppState = {
127 147
         shown: true;
128 148
         data: Spreadsheet;
129 149
       };
150
+  /** imageElement waiting to be placed on canvas */
151
+  pendingImageElement: NonDeleted<ExcalidrawImageElement> | null;
130 152
 };
131 153
 
132 154
 export type NormalizedZoomValue = number & { _brand: "normalizedZoom" };
@@ -172,6 +194,7 @@ export interface ExcalidrawProps {
172 194
   onChange?: (
173 195
     elements: readonly ExcalidrawElement[],
174 196
     appState: AppState,
197
+    files: BinaryFiles,
175 198
   ) => void;
176 199
   initialData?: ImportedDataState | null | Promise<ImportedDataState | null>;
177 200
   excalidrawRef?: ForwardRef<ExcalidrawAPIRefValue>;
@@ -207,6 +230,7 @@ export interface ExcalidrawProps {
207 230
   handleKeyboardGlobally?: boolean;
208 231
   onLibraryChange?: (libraryItems: LibraryItems) => void | Promise<any>;
209 232
   autoFocus?: boolean;
233
+  generateIdForFile?: (file: File) => string | Promise<string>;
210 234
 }
211 235
 
212 236
 export type SceneData = {
@@ -227,11 +251,13 @@ export type ExportOpts = {
227 251
   onExportToBackend?: (
228 252
     exportedElements: readonly NonDeletedExcalidrawElement[],
229 253
     appState: AppState,
254
+    files: BinaryFiles,
230 255
     canvas: HTMLCanvasElement | null,
231 256
   ) => void;
232 257
   renderCustomUI?: (
233 258
     exportedElements: readonly NonDeletedExcalidrawElement[],
234 259
     appState: AppState,
260
+    files: BinaryFiles,
235 261
     canvas: HTMLCanvasElement | null,
236 262
   ) => JSX.Element;
237 263
 };
@@ -258,6 +284,23 @@ export type AppProps = ExcalidrawProps & {
258 284
   handleKeyboardGlobally: boolean;
259 285
 };
260 286
 
287
+/** A subset of App class properties that we need to use elsewhere
288
+ * in the app, eg Manager. Factored out into a separate type to keep DRY. */
289
+export type AppClassProperties = {
290
+  props: AppProps;
291
+  canvas: HTMLCanvasElement | null;
292
+  focusContainer(): void;
293
+  library: Library;
294
+  imageCache: Map<
295
+    FileId,
296
+    {
297
+      image: HTMLImageElement | Promise<HTMLImageElement>;
298
+      mimeType: typeof ALLOWED_IMAGE_MIME_TYPES[number];
299
+    }
300
+  >;
301
+  files: BinaryFiles;
302
+};
303
+
261 304
 export type PointerDownState = Readonly<{
262 305
   // The first position at which pointerDown happened
263 306
   origin: Readonly<{ x: number; y: number }>;
@@ -327,9 +370,11 @@ export type ExcalidrawImperativeAPI = {
327 370
   scrollToContent: InstanceType<typeof App>["scrollToContent"];
328 371
   getSceneElements: InstanceType<typeof App>["getSceneElements"];
329 372
   getAppState: () => InstanceType<typeof App>["state"];
373
+  getFiles: () => InstanceType<typeof App>["files"];
330 374
   refresh: InstanceType<typeof App>["refresh"];
331 375
   importLibrary: InstanceType<typeof App>["importLibraryFromUrl"];
332 376
   setToastMessage: InstanceType<typeof App>["setToastMessage"];
377
+  addFiles: (data: BinaryFileData[]) => void;
333 378
   readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
334 379
   ready: true;
335 380
   id: string;

+ 9
- 3
src/utils.ts View File

@@ -10,8 +10,6 @@ import { Zoom } from "./types";
10 10
 import { unstable_batchedUpdates } from "react-dom";
11 11
 import { isDarwin } from "./keys";
12 12
 
13
-export const SVG_NS = "http://www.w3.org/2000/svg";
14
-
15 13
 let mockDateTime: string | null = null;
16 14
 
17 15
 export const setDateTimeForTests = (dateTime: string) => {
@@ -192,7 +190,9 @@ export const setCursorForShape = (
192 190
   }
193 191
   if (shape === "selection") {
194 192
     resetCursor(canvas);
195
-  } else {
193
+    // do nothing if image tool is selected which suggests there's
194
+    // a image-preview set as the cursor
195
+  } else if (shape !== "image") {
196 196
     canvas.style.cursor = CURSOR_TYPE.CROSSHAIR;
197 197
   }
198 198
 };
@@ -443,3 +443,9 @@ export const focusNearestParent = (element: HTMLInputElement) => {
443 443
     parent = parent.parentElement;
444 444
   }
445 445
 };
446
+
447
+export const preventUnload = (event: BeforeUnloadEvent) => {
448
+  event.preventDefault();
449
+  // NOTE: modern browsers no longer allow showing a custom message here
450
+  event.returnValue = "";
451
+};

+ 121
- 3
yarn.lock View File

@@ -2075,6 +2075,11 @@
2075 2075
   version "4.0.0"
2076 2076
   resolved "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz"
2077 2077
 
2078
+"@types/pica@5.1.3":
2079
+  version "5.1.3"
2080
+  resolved "https://registry.yarnpkg.com/@types/pica/-/pica-5.1.3.tgz#5ef64529a1f83f7d6586a8bf75a8a00be32aca02"
2081
+  integrity sha512-13SEyETRE5psd9bE0AmN+0M1tannde2fwHfLVaVIljkbL9V0OfFvKwCicyeDvVYLkmjQWEydbAlsDsmjrdyTOg==
2082
+
2078 2083
 "@types/prettier@^2.0.0":
2079 2084
   version "2.2.3"
2080 2085
   resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.2.3.tgz#ef65165aea2924c9359205bf748865b8881753c0"
@@ -3035,6 +3040,11 @@ balanced-match@^1.0.0:
3035 3040
   version "1.0.0"
3036 3041
   resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz"
3037 3042
 
3043
+base64-arraybuffer-es6@^0.7.0:
3044
+  version "0.7.0"
3045
+  resolved "https://registry.yarnpkg.com/base64-arraybuffer-es6/-/base64-arraybuffer-es6-0.7.0.tgz#dbe1e6c87b1bf1ca2875904461a7de40f21abc86"
3046
+  integrity sha512-ESyU/U1CFZDJUdr+neHRhNozeCv72Y7Vm0m1DCbjX3KBjT6eYocvAJlSk6+8+HkVwXlT1FNxhGW6q3UKAlCvvw==
3047
+
3038 3048
 base64-arraybuffer@0.1.4:
3039 3049
   version "0.1.4"
3040 3050
   resolved "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz"
@@ -4023,7 +4033,7 @@ core-js@3.6.5, core-js@^3.6.5:
4023 4033
   version "3.6.5"
4024 4034
   resolved "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz"
4025 4035
 
4026
-core-js@^2.4.0:
4036
+core-js@^2.4.0, core-js@^2.5.3:
4027 4037
   version "2.6.12"
4028 4038
   resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec"
4029 4039
 
@@ -4667,6 +4677,13 @@ domelementtype@^2.0.1:
4667 4677
   version "2.1.0"
4668 4678
   resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.1.0.tgz#a851c080a6d1c3d94344aed151d99f669edf585e"
4669 4679
 
4680
+domexception@^1.0.1:
4681
+  version "1.0.1"
4682
+  resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90"
4683
+  integrity sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug==
4684
+  dependencies:
4685
+    webidl-conversions "^4.0.2"
4686
+
4670 4687
 domexception@^2.0.1:
4671 4688
   version "2.0.1"
4672 4689
   resolved "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz"
@@ -5480,6 +5497,14 @@ extsprintf@1.3.0, extsprintf@^1.2.0:
5480 5497
   version "1.3.0"
5481 5498
   resolved "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz"
5482 5499
 
5500
+fake-indexeddb@3.1.3:
5501
+  version "3.1.3"
5502
+  resolved "https://registry.yarnpkg.com/fake-indexeddb/-/fake-indexeddb-3.1.3.tgz#76d59146a6b994b9bb50ac9949cbd96ad6cca760"
5503
+  integrity sha512-kpWYPIUGmxW8Q7xG7ampGL63fU/kYNukrIyy9KFj3+KVlFbE/SmvWebzWXBiCMeR0cPK6ufDoGC7MFkPhPLH9w==
5504
+  dependencies:
5505
+    realistic-structured-clone "^2.0.1"
5506
+    setimmediate "^1.0.5"
5507
+
5483 5508
 fast-deep-equal@^3.1.1:
5484 5509
   version "3.1.3"
5485 5510
   resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
@@ -6127,6 +6152,11 @@ globby@^6.1.0:
6127 6152
     pify "^2.0.0"
6128 6153
     pinkie-promise "^2.0.0"
6129 6154
 
6155
+glur@^1.1.2:
6156
+  version "1.1.2"
6157
+  resolved "https://registry.yarnpkg.com/glur/-/glur-1.1.2.tgz#f20ea36db103bfc292343921f1f91e83c3467689"
6158
+  integrity sha1-8g6jbbEDv8KSNDkh8fkeg8NGdok=
6159
+
6130 6160
 google-auth-library@^6.1.1, google-auth-library@^6.1.2, google-auth-library@^6.1.3:
6131 6161
   version "6.1.6"
6132 6162
   resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-6.1.6.tgz#deacdcdb883d9ed6bac78bb5d79a078877fdf572"
@@ -6545,6 +6575,13 @@ icss-utils@^4.0.0, icss-utils@^4.1.1:
6545 6575
   dependencies:
6546 6576
     postcss "^7.0.14"
6547 6577
 
6578
+idb-keyval@5.1.3:
6579
+  version "5.1.3"
6580
+  resolved "https://registry.yarnpkg.com/idb-keyval/-/idb-keyval-5.1.3.tgz#6ef5dff371897c23f144322dc6374eadd6a345d9"
6581
+  integrity sha512-N9HbCK/FaXSRVI+k6Xq4QgWxbcZRUv+SfG1y7HJ28JdV8yEJu6k+C/YLea7npGckX2DQJeEVuMc4bKOBeU/2LQ==
6582
+  dependencies:
6583
+    safari-14-idb-fix "^1.0.4"
6584
+
6548 6585
 idb@3.0.2:
6549 6586
   version "3.0.2"
6550 6587
   resolved "https://registry.npmjs.org/idb/-/idb-3.0.2.tgz"
@@ -6571,6 +6608,13 @@ ignore@^5.1.4:
6571 6608
   version "5.1.8"
6572 6609
   resolved "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz"
6573 6610
 
6611
+image-blob-reduce@3.0.1:
6612
+  version "3.0.1"
6613
+  resolved "https://registry.yarnpkg.com/image-blob-reduce/-/image-blob-reduce-3.0.1.tgz#812be7655a552031635799ae64e846b106f7a489"
6614
+  integrity sha512-/VmmWgIryG/wcn4TVrV7cC4mlfUC/oyiKIfSg5eVM3Ten/c1c34RJhMYKCWTnoSMHSqXLt3tsrBR4Q2HInvN+Q==
6615
+  dependencies:
6616
+    pica "^7.1.0"
6617
+
6574 6618
 immediate@~3.0.5:
6575 6619
   version "3.0.6"
6576 6620
   resolved "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz"
@@ -8112,7 +8156,7 @@ lodash.values@^2.4.1:
8112 8156
   dependencies:
8113 8157
     lodash.keys "~2.4.1"
8114 8158
 
8115
-"lodash@>=3.5 <5", lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.5:
8159
+"lodash@>=3.5 <5", lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.5, lodash@^4.7.0:
8116 8160
   version "4.17.21"
8117 8161
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
8118 8162
 
@@ -8536,6 +8580,14 @@ multicast-dns@^6.0.1:
8536 8580
     dns-packet "^1.3.1"
8537 8581
     thunky "^1.0.2"
8538 8582
 
8583
+multimath@^2.0.0:
8584
+  version "2.0.0"
8585
+  resolved "https://registry.yarnpkg.com/multimath/-/multimath-2.0.0.tgz#0d37acf67c328f30e3d8c6b0d3209e6082710302"
8586
+  integrity sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==
8587
+  dependencies:
8588
+    glur "^1.1.2"
8589
+    object-assign "^4.1.1"
8590
+
8539 8591
 mute-stream@0.0.7:
8540 8592
   version "0.0.7"
8541 8593
   resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz"
@@ -9274,6 +9326,17 @@ performance-now@^2.1.0:
9274 9326
   version "2.1.0"
9275 9327
   resolved "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz"
9276 9328
 
9329
+pica@^7.1.0:
9330
+  version "7.1.0"
9331
+  resolved "https://registry.yarnpkg.com/pica/-/pica-7.1.0.tgz#eb0b11abb3f2234ba8bdd0a839460f8fcd20e32a"
9332
+  integrity sha512-4D1E1lssL/yJD4La23Kbh5CSJPeXMO8NgJTsR/VWtG7aV92fD2g2t/PABg/Abp8Ug3yJNw7y7x1ftkJuIPLpEw==
9333
+  dependencies:
9334
+    glur "^1.1.2"
9335
+    inherits "^2.0.3"
9336
+    multimath "^2.0.0"
9337
+    object-assign "^4.1.1"
9338
+    webworkify "^1.5.0"
9339
+
9277 9340
 picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1, picomatch@^2.2.2:
9278 9341
   version "2.2.2"
9279 9342
   resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz"
@@ -10502,6 +10565,16 @@ readdirp@~3.5.0:
10502 10565
   dependencies:
10503 10566
     picomatch "^2.2.1"
10504 10567
 
10568
+realistic-structured-clone@^2.0.1:
10569
+  version "2.0.3"
10570
+  resolved "https://registry.yarnpkg.com/realistic-structured-clone/-/realistic-structured-clone-2.0.3.tgz#8a252a87db8278d92267ad7a168c4f43fa485795"
10571
+  integrity sha512-XYTwWZi5+lU4Wf+rnsQ7pukN9hF2cbJJf/yruBr1w23WhGflM6WoTBkdMVAun+oHFW2mV7UquyYo5oOI7YLJrQ==
10572
+  dependencies:
10573
+    core-js "^2.5.3"
10574
+    domexception "^1.0.1"
10575
+    typeson "^6.1.0"
10576
+    typeson-registry "^1.0.0-alpha.20"
10577
+
10505 10578
 recursive-readdir@2.2.2:
10506 10579
   version "2.2.2"
10507 10580
   resolved "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.2.tgz"
@@ -10916,6 +10989,11 @@ rxjs@^6.4.0, rxjs@^6.6.0, rxjs@^6.6.6:
10916 10989
   dependencies:
10917 10990
     tslib "^1.9.0"
10918 10991
 
10992
+safari-14-idb-fix@^1.0.4:
10993
+  version "1.0.4"
10994
+  resolved "https://registry.yarnpkg.com/safari-14-idb-fix/-/safari-14-idb-fix-1.0.4.tgz#5c68ba63e2a8ae0d89a0aa1e13fe89e3aef7da19"
10995
+  integrity sha512-4+Y2baQdgJpzu84d0QjySl70Kyygzf0pepVg8NVg4NnQEPpfC91fAn0baNvtStlCjUUxxiu0BOMiafa98fRRuA==
10996
+
10919 10997
 safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
10920 10998
   version "5.1.2"
10921 10999
   resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz"
@@ -11122,7 +11200,7 @@ set-value@^2.0.0, set-value@^2.0.1:
11122 11200
     is-plain-object "^2.0.3"
11123 11201
     split-string "^3.0.1"
11124 11202
 
11125
-setimmediate@^1.0.4, setimmediate@~1.0.4:
11203
+setimmediate@^1.0.4, setimmediate@^1.0.5, setimmediate@~1.0.4:
11126 11204
   version "1.0.5"
11127 11205
   resolved "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz"
11128 11206
 
@@ -12062,6 +12140,13 @@ tr46@^2.0.2:
12062 12140
   dependencies:
12063 12141
     punycode "^2.1.1"
12064 12142
 
12143
+tr46@^2.1.0:
12144
+  version "2.1.0"
12145
+  resolved "https://registry.yarnpkg.com/tr46/-/tr46-2.1.0.tgz#fa87aa81ca5d5941da8cbf1f9b749dc969a4e240"
12146
+  integrity sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==
12147
+  dependencies:
12148
+    punycode "^2.1.1"
12149
+
12065 12150
 "traverse@>=0.3.0 <0.4":
12066 12151
   version "0.3.9"
12067 12152
   resolved "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz"
@@ -12196,6 +12281,20 @@ typescript@4.2.4:
12196 12281
   resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.4.tgz#8610b59747de028fda898a8aef0e103f156d0961"
12197 12282
   integrity sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==
12198 12283
 
12284
+typeson-registry@^1.0.0-alpha.20:
12285
+  version "1.0.0-alpha.39"
12286
+  resolved "https://registry.yarnpkg.com/typeson-registry/-/typeson-registry-1.0.0-alpha.39.tgz#9e0f5aabd5eebfcffd65a796487541196f4b1211"
12287
+  integrity sha512-NeGDEquhw+yfwNhguLPcZ9Oj0fzbADiX4R0WxvoY8nGhy98IbzQy1sezjoEFWOywOboj/DWehI+/aUlRVrJnnw==
12288
+  dependencies:
12289
+    base64-arraybuffer-es6 "^0.7.0"
12290
+    typeson "^6.0.0"
12291
+    whatwg-url "^8.4.0"
12292
+
12293
+typeson@^6.0.0, typeson@^6.1.0:
12294
+  version "6.1.0"
12295
+  resolved "https://registry.yarnpkg.com/typeson/-/typeson-6.1.0.tgz#5b2a53705a5f58ff4d6f82f965917cabd0d7448b"
12296
+  integrity sha512-6FTtyGr8ldU0pfbvW/eOZrEtEkczHRUtduBnA90Jh9kMPCiFNnXIon3vF41N0S4tV1HHQt4Hk1j4srpESziCaA==
12297
+
12199 12298
 unbox-primitive@^1.0.0:
12200 12299
   version "1.0.1"
12201 12300
   resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471"
@@ -12517,6 +12616,11 @@ wcwidth@^1.0.1:
12517 12616
   dependencies:
12518 12617
     defaults "^1.0.3"
12519 12618
 
12619
+webidl-conversions@^4.0.2:
12620
+  version "4.0.2"
12621
+  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
12622
+  integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==
12623
+
12520 12624
 webidl-conversions@^5.0.0:
12521 12625
   version "5.0.0"
12522 12626
   resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz"
@@ -12636,6 +12740,11 @@ websocket-extensions@>=0.1.1:
12636 12740
   version "0.1.4"
12637 12741
   resolved "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz"
12638 12742
 
12743
+webworkify@^1.5.0:
12744
+  version "1.5.0"
12745
+  resolved "https://registry.yarnpkg.com/webworkify/-/webworkify-1.5.0.tgz#734ad87a774de6ebdd546e1d3e027da5b8f4a42c"
12746
+  integrity sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==
12747
+
12639 12748
 whatwg-encoding@^1.0.5:
12640 12749
   version "1.0.5"
12641 12750
   resolved "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz"
@@ -12662,6 +12771,15 @@ whatwg-url@^8.0.0:
12662 12771
     tr46 "^2.0.2"
12663 12772
     webidl-conversions "^6.1.0"
12664 12773
 
12774
+whatwg-url@^8.4.0:
12775
+  version "8.7.0"
12776
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.7.0.tgz#656a78e510ff8f3937bc0bcbe9f5c0ac35941b77"
12777
+  integrity sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==
12778
+  dependencies:
12779
+    lodash "^4.7.0"
12780
+    tr46 "^2.1.0"
12781
+    webidl-conversions "^6.1.0"
12782
+
12665 12783
 which-boxed-primitive@^1.0.2:
12666 12784
   version "1.0.2"
12667 12785
   resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"

Loading…
Cancel
Save