소스 검색

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 년 전
부모
커밋
163ad1f4c4
No account linked to committer's email address
85개의 변경된 파일3536개의 추가작업 그리고 618개의 파일을 삭제
  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 파일 보기

5
 firebase/
5
 firebase/
6
 dist/
6
 dist/
7
 public/workbox
7
 public/workbox
8
+src/packages/excalidraw/types

+ 4
- 0
package.json 파일 보기

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

+ 3
- 1
src/actions/actionCanvas.tsx 파일 보기

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

+ 4
- 2
src/actions/actionClipboard.tsx 파일 보기

9
 
9
 
10
 export const actionCopy = register({
10
 export const actionCopy = register({
11
   name: "copy",
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
     return {
15
     return {
16
       commitToHistory: false,
16
       commitToHistory: false,
50
           ? selectedElements
50
           ? selectedElements
51
           : getNonDeletedElements(elements),
51
           : getNonDeletedElements(elements),
52
         appState,
52
         appState,
53
+        app.files,
53
         appState,
54
         appState,
54
       );
55
       );
55
       return {
56
       return {
88
           ? selectedElements
89
           ? selectedElements
89
           : getNonDeletedElements(elements),
90
           : getNonDeletedElements(elements),
90
         appState,
91
         appState,
92
+        app.files,
91
         appState,
93
         appState,
92
       );
94
       );
93
       return {
95
       return {

+ 16
- 9
src/actions/actionExport.tsx 파일 보기

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

+ 6
- 0
src/actions/actionFinalize.tsx 파일 보기

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

+ 4
- 4
src/actions/actionFlip.ts 파일 보기

93
   appState: AppState,
93
   appState: AppState,
94
   flipDirection: "horizontal" | "vertical",
94
   flipDirection: "horizontal" | "vertical",
95
 ): ExcalidrawElement[] => {
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
     // If vertical flip, rotate an extra 180
98
     // If vertical flip, rotate an extra 180
99
     if (flipDirection === "vertical") {
99
     if (flipDirection === "vertical") {
100
-      rotateElement(elements[i], Math.PI);
100
+      rotateElement(element, Math.PI);
101
     }
101
     }
102
-  }
102
+  });
103
   return elements;
103
   return elements;
104
 };
104
 };
105
 
105
 

+ 8
- 5
src/actions/actionProperties.tsx 파일 보기

59
   getTargetElements,
59
   getTargetElements,
60
   isSomeElementSelected,
60
   isSomeElementSelected,
61
 } from "../scene";
61
 } from "../scene";
62
+import { hasStrokeColor } from "../scene/comparisons";
62
 import { register } from "./register";
63
 import { register } from "./register";
63
 
64
 
64
 const changeProperty = (
65
 const changeProperty = (
103
   perform: (elements, appState, value) => {
104
   perform: (elements, appState, value) => {
104
     return {
105
     return {
105
       ...(value.currentItemStrokeColor && {
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
       appState: {
115
       appState: {
113
         ...appState,
116
         ...appState,

+ 3
- 13
src/actions/manager.tsx 파일 보기

8
   PanelComponentProps,
8
   PanelComponentProps,
9
 } from "./types";
9
 } from "./types";
10
 import { ExcalidrawElement } from "../element/types";
10
 import { ExcalidrawElement } from "../element/types";
11
-import { AppProps, AppState } from "../types";
11
+import { AppClassProperties, AppState } from "../types";
12
 import { MODES } from "../constants";
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
 export class ActionManager implements ActionsManagerInterface {
14
 export class ActionManager implements ActionsManagerInterface {
25
   actions = {} as ActionsManagerInterface["actions"];
15
   actions = {} as ActionsManagerInterface["actions"];
28
 
18
 
29
   getAppState: () => Readonly<AppState>;
19
   getAppState: () => Readonly<AppState>;
30
   getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
20
   getElementsIncludingDeleted: () => readonly ExcalidrawElement[];
31
-  app: App;
21
+  app: AppClassProperties;
32
 
22
 
33
   constructor(
23
   constructor(
34
     updater: UpdaterFn,
24
     updater: UpdaterFn,
35
     getAppState: () => AppState,
25
     getAppState: () => AppState,
36
     getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
26
     getElementsIncludingDeleted: () => readonly ExcalidrawElement[],
37
-    app: App,
27
+    app: AppClassProperties,
38
   ) {
28
   ) {
39
     this.updater = (actionResult) => {
29
     this.updater = (actionResult) => {
40
       if (actionResult && "then" in actionResult) {
30
       if (actionResult && "then" in actionResult) {

+ 9
- 9
src/actions/types.ts 파일 보기

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

+ 83
- 67
src/appState.ts 파일 보기

79
     zenModeEnabled: false,
79
     zenModeEnabled: false,
80
     zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
80
     zoom: { value: 1 as NormalizedZoomValue, translation: { x: 0, y: 0 } },
81
     viewModeEnabled: false,
81
     viewModeEnabled: false,
82
+    pendingImageElement: null,
82
   };
83
   };
83
 };
84
 };
84
 
85
 
92
     browser: boolean;
93
     browser: boolean;
93
     /** whether to keep when exporting to file/database */
94
     /** whether to keep when exporting to file/database */
94
     export: boolean;
95
     export: boolean;
96
+    /** server (shareLink/collab/...) */
97
+    server: boolean;
95
   },
98
   },
96
   T extends Record<keyof AppState, Values>
99
   T extends Record<keyof AppState, Values>
97
 >(
100
 >(
98
   config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
101
   config: { [K in keyof T]: K extends keyof AppState ? T[K] : never },
99
 ) => config)({
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
   appState: Partial<AppState>,
177
   appState: Partial<AppState>,
168
   exportType: ExportType,
178
   exportType: ExportType,
169
 ) => {
179
 ) => {
176
   for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
186
   for (const key of Object.keys(appState) as (keyof typeof appState)[]) {
177
     const propConfig = APP_STATE_STORAGE_CONF[key];
187
     const propConfig = APP_STATE_STORAGE_CONF[key];
178
     if (propConfig?.[exportType]) {
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
   return stateForExport;
195
   return stateForExport;
190
 export const cleanAppStateForExport = (appState: Partial<AppState>) => {
202
 export const cleanAppStateForExport = (appState: Partial<AppState>) => {
191
   return _clearAppStateForStorage(appState, "export");
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 파일 보기

3
   NonDeletedExcalidrawElement,
3
   NonDeletedExcalidrawElement,
4
 } from "./element/types";
4
 } from "./element/types";
5
 import { getSelectedElements } from "./scene";
5
 import { getSelectedElements } from "./scene";
6
-import { AppState } from "./types";
6
+import { AppState, BinaryFiles } from "./types";
7
 import { SVG_EXPORT_TAG } from "./scene/export";
7
 import { SVG_EXPORT_TAG } from "./scene/export";
8
 import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
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
 type ElementsClipboard = {
12
 type ElementsClipboard = {
12
   type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
13
   type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
13
   elements: ExcalidrawElement[];
14
   elements: ExcalidrawElement[];
15
+  files: BinaryFiles | undefined;
14
 };
16
 };
15
 
17
 
16
 export interface ClipboardData {
18
 export interface ClipboardData {
17
   spreadsheet?: Spreadsheet;
19
   spreadsheet?: Spreadsheet;
18
   elements?: readonly ExcalidrawElement[];
20
   elements?: readonly ExcalidrawElement[];
21
+  files?: BinaryFiles;
19
   text?: string;
22
   text?: string;
20
   errorMessage?: string;
23
   errorMessage?: string;
21
 }
24
 }
37
 
40
 
38
 const clipboardContainsElements = (
41
 const clipboardContainsElements = (
39
   contents: any,
42
   contents: any,
40
-): contents is { elements: ExcalidrawElement[] } => {
43
+): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => {
41
   if (
44
   if (
42
     [
45
     [
43
       EXPORT_DATA_TYPES.excalidraw,
46
       EXPORT_DATA_TYPES.excalidraw,
53
 export const copyToClipboard = async (
56
 export const copyToClipboard = async (
54
   elements: readonly NonDeletedExcalidrawElement[],
57
   elements: readonly NonDeletedExcalidrawElement[],
55
   appState: AppState,
58
   appState: AppState,
59
+  files: BinaryFiles,
56
 ) => {
60
 ) => {
61
+  const selectedElements = getSelectedElements(elements, appState);
57
   const contents: ElementsClipboard = {
62
   const contents: ElementsClipboard = {
58
     type: EXPORT_DATA_TYPES.excalidrawClipboard,
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
   const json = JSON.stringify(contents);
72
   const json = JSON.stringify(contents);
62
   CLIPBOARD = json;
73
   CLIPBOARD = json;
138
   try {
149
   try {
139
     const systemClipboardData = JSON.parse(systemClipboard);
150
     const systemClipboardData = JSON.parse(systemClipboard);
140
     if (clipboardContainsElements(systemClipboardData)) {
151
     if (clipboardContainsElements(systemClipboardData)) {
141
-      return { elements: systemClipboardData.elements };
152
+      return {
153
+        elements: systemClipboardData.elements,
154
+        files: systemClipboardData.files,
155
+      };
142
     }
156
     }
143
     return appClipboardData;
157
     return appClipboardData;
144
   } catch {
158
   } catch {
153
 
167
 
154
 export const copyBlobToClipboardAsPng = async (blob: Blob) => {
168
 export const copyBlobToClipboardAsPng = async (blob: Blob) => {
155
   await navigator.clipboard.write([
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 파일 보기

1
 import React from "react";
1
 import React from "react";
2
 import { ActionManager } from "../actions/manager";
2
 import { ActionManager } from "../actions/manager";
3
 import { getNonDeletedElements } from "../element";
3
 import { getNonDeletedElements } from "../element";
4
-import { ExcalidrawElement } from "../element/types";
4
+import { ExcalidrawElement, PointerType } from "../element/types";
5
 import { t } from "../i18n";
5
 import { t } from "../i18n";
6
 import { useIsMobile } from "../components/App";
6
 import { useIsMobile } from "../components/App";
7
 import {
7
 import {
18
 import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
18
 import { capitalizeString, isTransparent, setCursorForShape } from "../utils";
19
 import Stack from "./Stack";
19
 import Stack from "./Stack";
20
 import { ToolButton } from "./ToolButton";
20
 import { ToolButton } from "./ToolButton";
21
+import { hasStrokeColor } from "../scene/comparisons";
21
 
22
 
22
 export const SelectedShapeActions = ({
23
 export const SelectedShapeActions = ({
23
   appState,
24
   appState,
48
     hasBackground(elementType) ||
49
     hasBackground(elementType) ||
49
     targetElements.some((element) => hasBackground(element.type));
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
   return (
61
   return (
52
     <div className="panelColumn">
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
       {showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
68
       {showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
55
       {showFillIcons && renderAction("changeFillStyle")}
69
       {showFillIcons && renderAction("changeFillStyle")}
56
 
70
 
155
   canvas,
169
   canvas,
156
   elementType,
170
   elementType,
157
   setAppState,
171
   setAppState,
172
+  onImageAction,
158
 }: {
173
 }: {
159
   canvas: HTMLCanvasElement | null;
174
   canvas: HTMLCanvasElement | null;
160
   elementType: ExcalidrawElement["type"];
175
   elementType: ExcalidrawElement["type"];
161
   setAppState: React.Component<any, AppState>["setState"];
176
   setAppState: React.Component<any, AppState>["setState"];
177
+  onImageAction: (data: { pointerType: PointerType | null }) => void;
162
 }) => (
178
 }) => (
163
   <>
179
   <>
164
     {SHAPES.map(({ value, icon, key }, index) => {
180
     {SHAPES.map(({ value, icon, key }, index) => {
165
       const label = t(`toolBar.${value}`);
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
       return (
186
       return (
171
         <ToolButton
187
         <ToolButton
172
           className="Shape"
188
           className="Shape"
180
           aria-label={capitalizeString(label)}
196
           aria-label={capitalizeString(label)}
181
           aria-keyshortcuts={shortcut}
197
           aria-keyshortcuts={shortcut}
182
           data-testid={value}
198
           data-testid={value}
183
-          onChange={() => {
199
+          onChange={({ pointerType }) => {
184
             setAppState({
200
             setAppState({
185
               elementType: value,
201
               elementType: value,
186
               multiElement: null,
202
               multiElement: null,
187
               selectedElementIds: {},
203
               selectedElementIds: {},
188
             });
204
             });
189
             setCursorForShape(canvas, value);
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
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
파일 보기


+ 4
- 0
src/components/Card.scss 파일 보기

48
       .ToolIcon__label {
48
       .ToolIcon__label {
49
         color: $oc-white;
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 파일 보기

157
                   shortcuts={["Shift+P", "7"]}
157
                   shortcuts={["Shift+P", "7"]}
158
                 />
158
                 />
159
                 <Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
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
                 <Shortcut
162
                 <Shortcut
161
                   label={t("helpDialog.editSelectedShape")}
163
                   label={t("helpDialog.editSelectedShape")}
162
                   shortcuts={[
164
                   shortcuts={[

+ 12
- 2
src/components/HintViewer.tsx 파일 보기

4
 
4
 
5
 import "./HintViewer.scss";
5
 import "./HintViewer.scss";
6
 import { AppState } from "../types";
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
 import { getShortcutKey } from "../utils";
12
 import { getShortcutKey } from "../utils";
9
 
13
 
10
 interface Hint {
14
 interface Hint {
30
     return t("hints.text");
34
     return t("hints.text");
31
   }
35
   }
32
 
36
 
37
+  if (appState.elementType === "image" && appState.pendingImageElement) {
38
+    return t("hints.placeImage");
39
+  }
40
+
33
   const selectedElements = getSelectedElements(elements, appState);
41
   const selectedElements = getSelectedElements(elements, appState);
34
   if (
42
   if (
35
     isResizing &&
43
     isResizing &&
40
     if (isLinearElement(targetElement) && targetElement.points.length === 2) {
48
     if (isLinearElement(targetElement) && targetElement.points.length === 2) {
41
       return t("hints.lockAngle");
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
   if (isRotating && lastPointerDownWith === "mouse") {
56
   if (isRotating && lastPointerDownWith === "mouse") {

+ 21
- 20
src/components/ImageExportDialog.tsx 파일 보기

9
 import { useIsMobile } from "./App";
9
 import { useIsMobile } from "./App";
10
 import { getSelectedElements, isSomeElementSelected } from "../scene";
10
 import { getSelectedElements, isSomeElementSelected } from "../scene";
11
 import { exportToCanvas } from "../scene/export";
11
 import { exportToCanvas } from "../scene/export";
12
-import { AppState } from "../types";
12
+import { AppState, BinaryFiles } from "../types";
13
 import { Dialog } from "./Dialog";
13
 import { Dialog } from "./Dialog";
14
 import { clipboard, exportImage } from "./icons";
14
 import { clipboard, exportImage } from "./icons";
15
 import Stack from "./Stack";
15
 import Stack from "./Stack";
79
 const ImageExportModal = ({
79
 const ImageExportModal = ({
80
   elements,
80
   elements,
81
   appState,
81
   appState,
82
+  files,
82
   exportPadding = DEFAULT_EXPORT_PADDING,
83
   exportPadding = DEFAULT_EXPORT_PADDING,
83
   actionManager,
84
   actionManager,
84
   onExportToPng,
85
   onExportToPng,
87
 }: {
88
 }: {
88
   appState: AppState;
89
   appState: AppState;
89
   elements: readonly NonDeletedExcalidrawElement[];
90
   elements: readonly NonDeletedExcalidrawElement[];
91
+  files: BinaryFiles;
90
   exportPadding?: number;
92
   exportPadding?: number;
91
   actionManager: ActionsManagerInterface;
93
   actionManager: ActionsManagerInterface;
92
   onExportToPng: ExportCB;
94
   onExportToPng: ExportCB;
112
     if (!previewNode) {
114
     if (!previewNode) {
113
       return;
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
           renderPreview(canvas, previewNode);
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
     appState,
134
     appState,
135
+    files,
138
     exportedElements,
136
     exportedElements,
139
     exportBackground,
137
     exportBackground,
140
     exportPadding,
138
     exportPadding,
220
 export const ImageExportDialog = ({
218
 export const ImageExportDialog = ({
221
   elements,
219
   elements,
222
   appState,
220
   appState,
221
+  files,
223
   exportPadding = DEFAULT_EXPORT_PADDING,
222
   exportPadding = DEFAULT_EXPORT_PADDING,
224
   actionManager,
223
   actionManager,
225
   onExportToPng,
224
   onExportToPng,
228
 }: {
227
 }: {
229
   appState: AppState;
228
   appState: AppState;
230
   elements: readonly NonDeletedExcalidrawElement[];
229
   elements: readonly NonDeletedExcalidrawElement[];
230
+  files: BinaryFiles;
231
   exportPadding?: number;
231
   exportPadding?: number;
232
   actionManager: ActionsManagerInterface;
232
   actionManager: ActionsManagerInterface;
233
   onExportToPng: ExportCB;
233
   onExportToPng: ExportCB;
258
           <ImageExportModal
258
           <ImageExportModal
259
             elements={elements}
259
             elements={elements}
260
             appState={appState}
260
             appState={appState}
261
+            files={files}
261
             exportPadding={exportPadding}
262
             exportPadding={exportPadding}
262
             actionManager={actionManager}
263
             actionManager={actionManager}
263
             onExportToPng={onExportToPng}
264
             onExportToPng={onExportToPng}

+ 11
- 4
src/components/JSONExportDialog.tsx 파일 보기

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

+ 39
- 7
src/components/LayerUI.tsx 파일 보기

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

+ 2
- 2
src/components/LibraryButton.tsx 파일 보기

26
           "zen-mode-visibility--hidden": appState.zenModeEnabled,
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
       style={{ marginInlineStart: "var(--space-factor)" }}
30
       style={{ marginInlineStart: "var(--space-factor)" }}
31
     >
31
     >
32
       <input
32
       <input
38
         }}
38
         }}
39
         checked={appState.isLibraryOpen}
39
         checked={appState.isLibraryOpen}
40
         aria-label={capitalizeString(t("toolBar.library"))}
40
         aria-label={capitalizeString(t("toolBar.library"))}
41
-        aria-keyshortcuts="9"
41
+        aria-keyshortcuts="0"
42
       />
42
       />
43
       <div className="ToolIcon__icon">{LIBRARY_ICON}</div>
43
       <div className="ToolIcon__icon">{LIBRARY_ICON}</div>
44
     </label>
44
     </label>

+ 18
- 25
src/components/LibraryUnit.tsx 파일 보기

6
 import { t } from "../i18n";
6
 import { t } from "../i18n";
7
 import { useIsMobile } from "../components/App";
7
 import { useIsMobile } from "../components/App";
8
 import { exportToSvg } from "../scene/export";
8
 import { exportToSvg } from "../scene/export";
9
-import { LibraryItem } from "../types";
9
+import { BinaryFiles, LibraryItem } from "../types";
10
 import "./LibraryUnit.scss";
10
 import "./LibraryUnit.scss";
11
 
11
 
12
 // fa-plus
12
 // fa-plus
21
 
21
 
22
 export const LibraryUnit = ({
22
 export const LibraryUnit = ({
23
   elements,
23
   elements,
24
+  files,
24
   pendingElements,
25
   pendingElements,
25
   onRemoveFromLibrary,
26
   onRemoveFromLibrary,
26
   onClick,
27
   onClick,
27
 }: {
28
 }: {
28
   elements?: LibraryItem;
29
   elements?: LibraryItem;
30
+  files: BinaryFiles;
29
   pendingElements?: LibraryItem;
31
   pendingElements?: LibraryItem;
30
   onRemoveFromLibrary: () => void;
32
   onRemoveFromLibrary: () => void;
31
   onClick: () => void;
33
   onClick: () => void;
32
 }) => {
34
 }) => {
33
   const ref = useRef<HTMLDivElement | null>(null);
35
   const ref = useRef<HTMLDivElement | null>(null);
34
   useEffect(() => {
36
   useEffect(() => {
35
-    const elementsToRender = elements || pendingElements;
36
-    if (!elementsToRender) {
37
-      return;
38
-    }
39
-    let svg: SVGSVGElement;
40
-    const current = ref.current!;
41
-
42
     (async () => {
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
   const [isHovered, setIsHovered] = useState(false);
56
   const [isHovered, setIsHovered] = useState(false);
64
   const isMobile = useIsMobile();
57
   const isMobile = useIsMobile();

+ 7
- 0
src/components/MobileMenu.tsx 파일 보기

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

+ 8
- 4
src/components/PasteChartDialog.tsx 파일 보기

38
     const previewNode = previewRef.current!;
38
     const previewNode = previewRef.current!;
39
 
39
 
40
     (async () => {
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
       previewNode.appendChild(svg);
50
       previewNode.appendChild(svg);
47
 
51
 

+ 48
- 0
src/components/Spinner.scss 파일 보기

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 파일 보기

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 파일 보기

1
 import "./ToolIcon.scss";
1
 import "./ToolIcon.scss";
2
 
2
 
3
-import React from "react";
3
+import React, { useEffect, useRef, useState } from "react";
4
 import clsx from "clsx";
4
 import clsx from "clsx";
5
 import { useExcalidrawContainer } from "./App";
5
 import { useExcalidrawContainer } from "./App";
6
+import { AbortError } from "../errors";
7
+import Spinner from "./Spinner";
8
+import { PointerType } from "../element/types";
6
 
9
 
7
 export type ToolButtonSize = "small" | "medium";
10
 export type ToolButtonSize = "small" | "medium";
8
 
11
 
28
   | (ToolButtonBaseProps & {
31
   | (ToolButtonBaseProps & {
29
       type: "button";
32
       type: "button";
30
       children?: React.ReactNode;
33
       children?: React.ReactNode;
31
-      onClick?(): void;
34
+      onClick?(event: React.MouseEvent): void;
32
     })
35
     })
33
   | (ToolButtonBaseProps & {
36
   | (ToolButtonBaseProps & {
34
       type: "icon";
37
       type: "icon";
38
   | (ToolButtonBaseProps & {
41
   | (ToolButtonBaseProps & {
39
       type: "radio";
42
       type: "radio";
40
       checked: boolean;
43
       checked: boolean;
41
-      onChange?(): void;
44
+      onChange?(data: { pointerType: PointerType | null }): void;
42
     });
45
     });
43
 
46
 
44
 export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
47
 export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
47
   React.useImperativeHandle(ref, () => innerRef.current);
50
   React.useImperativeHandle(ref, () => innerRef.current);
48
   const sizeCn = `ToolIcon_size_${props.size}`;
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
   if (props.type === "button" || props.type === "icon") {
85
   if (props.type === "button" || props.type === "icon") {
51
     return (
86
     return (
52
       <button
87
       <button
68
         title={props.title}
103
         title={props.title}
69
         aria-label={props["aria-label"]}
104
         aria-label={props["aria-label"]}
70
         type="button"
105
         type="button"
71
-        onClick={props.onClick}
106
+        onClick={onClick}
72
         ref={innerRef}
107
         ref={innerRef}
108
+        disabled={isLoading}
73
       >
109
       >
74
         {(props.icon || props.label) && (
110
         {(props.icon || props.label) && (
75
           <div className="ToolIcon__icon" aria-hidden="true">
111
           <div className="ToolIcon__icon" aria-hidden="true">
82
           </div>
118
           </div>
83
         )}
119
         )}
84
         {props.showAriaLabel && (
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
         {props.children}
125
         {props.children}
88
       </button>
126
       </button>
90
   }
128
   }
91
 
129
 
92
   return (
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
       <input
143
       <input
95
         className={`ToolIcon_type_radio ${sizeCn}`}
144
         className={`ToolIcon_type_radio ${sizeCn}`}
96
         type="radio"
145
         type="radio"
99
         aria-keyshortcuts={props["aria-keyshortcuts"]}
148
         aria-keyshortcuts={props["aria-keyshortcuts"]}
100
         data-testid={props["data-testid"]}
149
         data-testid={props["data-testid"]}
101
         id={`${excalId}-${props.id}`}
150
         id={`${excalId}-${props.id}`}
102
-        onChange={props.onChange}
151
+        onChange={() => {
152
+          props.onChange?.({ pointerType: lastPointerTypeRef.current });
153
+        }}
103
         checked={props.checked}
154
         checked={props.checked}
104
         ref={innerRef}
155
         ref={innerRef}
105
       />
156
       />

+ 6
- 0
src/components/ToolIcon.scss 파일 보기

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

+ 20
- 0
src/constants.ts 파일 보기

90
 export const MIME_TYPES = {
90
 export const MIME_TYPES = {
91
   excalidraw: "application/vnd.excalidraw+json",
91
   excalidraw: "application/vnd.excalidraw+json",
92
   excalidrawlib: "application/vnd.excalidrawlib+json",
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
 } as const;
99
 } as const;
94
 
100
 
95
 export const EXPORT_DATA_TYPES = {
101
 export const EXPORT_DATA_TYPES = {
105
 } as const;
111
 } as const;
106
 
112
 
107
 // time in milliseconds
113
 // time in milliseconds
114
+export const IMAGE_RENDER_TIMEOUT = 500;
108
 export const TAP_TWICE_TIMEOUT = 300;
115
 export const TAP_TWICE_TIMEOUT = 300;
109
 export const TOUCH_CTX_MENU_TIMEOUT = 500;
116
 export const TOUCH_CTX_MENU_TIMEOUT = 500;
110
 export const TITLE_TIMEOUT = 10000;
117
 export const TITLE_TIMEOUT = 10000;
154
 
161
 
155
 export const EXPORT_SCALES = [1, 2, 3];
162
 export const EXPORT_SCALES = [1, 2, 3];
156
 export const DEFAULT_EXPORT_PADDING = 10; // px
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 파일 보기

1
+import { nanoid } from "nanoid";
1
 import { cleanAppStateForExport } from "../appState";
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
 import { clearElementsForExport } from "../element";
8
 import { clearElementsForExport } from "../element";
4
-import { ExcalidrawElement } from "../element/types";
9
+import { ExcalidrawElement, FileId } from "../element/types";
5
 import { CanvasError } from "../errors";
10
 import { CanvasError } from "../errors";
6
 import { t } from "../i18n";
11
 import { t } from "../i18n";
7
 import { calculateScrollCenter } from "../scene";
12
 import { calculateScrollCenter } from "../scene";
8
-import { AppState } from "../types";
13
+import { AppState, DataURL } from "../types";
9
 import { FileSystemHandle } from "./filesystem";
14
 import { FileSystemHandle } from "./filesystem";
10
 import { isValidExcalidrawData } from "./json";
15
 import { isValidExcalidrawData } from "./json";
11
 import { restore } from "./restore";
16
 import { restore } from "./restore";
14
 const parseFileContents = async (blob: Blob | File) => {
19
 const parseFileContents = async (blob: Blob | File) => {
15
   let contents: string;
20
   let contents: string;
16
 
21
 
17
-  if (blob.type === "image/png") {
22
+  if (blob.type === MIME_TYPES.png) {
18
     try {
23
     try {
19
       return await (
24
       return await (
20
         await import(/* webpackChunkName: "image" */ "./image")
25
         await import(/* webpackChunkName: "image" */ "./image")
21
       ).decodePngMetadata(blob);
26
       ).decodePngMetadata(blob);
22
     } catch (error) {
27
     } catch (error) {
23
       if (error.message === "INVALID") {
28
       if (error.message === "INVALID") {
24
-        throw new Error(t("alerts.imageDoesNotContainScene"));
29
+        throw new DOMException(
30
+          t("alerts.imageDoesNotContainScene"),
31
+          "EncodingError",
32
+        );
25
       } else {
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
   } else {
40
   } else {
40
         };
51
         };
41
       });
52
       });
42
     }
53
     }
43
-    if (blob.type === "image/svg+xml") {
54
+    if (blob.type === MIME_TYPES.svg) {
44
       try {
55
       try {
45
         return await (
56
         return await (
46
           await import(/* webpackChunkName: "image" */ "./image")
57
           await import(/* webpackChunkName: "image" */ "./image")
49
         });
60
         });
50
       } catch (error) {
61
       } catch (error) {
51
         if (error.message === "INVALID") {
62
         if (error.message === "INVALID") {
52
-          throw new Error(t("alerts.imageDoesNotContainScene"));
63
+          throw new DOMException(
64
+            t("alerts.imageDoesNotContainScene"),
65
+            "EncodingError",
66
+          );
53
         } else {
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
     name = blob.name || "";
87
     name = blob.name || "";
71
   }
88
   }
72
   if (/\.(excalidraw|json)$/.test(name)) {
89
   if (/\.(excalidraw|json)$/.test(name)) {
73
-    return "application/json";
90
+    return MIME_TYPES.json;
74
   } else if (/\.png$/.test(name)) {
91
   } else if (/\.png$/.test(name)) {
75
-    return "image/png";
92
+    return MIME_TYPES.png;
76
   } else if (/\.jpe?g$/.test(name)) {
93
   } else if (/\.jpe?g$/.test(name)) {
77
-    return "image/jpeg";
94
+    return MIME_TYPES.jpg;
78
   } else if (/\.svg$/.test(name)) {
95
   } else if (/\.svg$/.test(name)) {
79
-    return "image/svg+xml";
96
+    return MIME_TYPES.svg;
80
   }
97
   }
81
   return "";
98
   return "";
82
 };
99
 };
100
   return type === "png" || type === "svg";
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
 export const loadFromBlob = async (
129
 export const loadFromBlob = async (
104
   blob: Blob,
130
   blob: Blob,
105
   /** @see restore.localAppState */
131
   /** @see restore.localAppState */
123
             ? calculateScrollCenter(data.elements || [], localAppState, null)
149
             ? calculateScrollCenter(data.elements || [], localAppState, null)
124
             : {}),
150
             : {}),
125
         },
151
         },
152
+        files: data.files,
126
       },
153
       },
127
       localAppState,
154
       localAppState,
128
       localElements,
155
       localElements,
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 파일 보기

1
 import { deflate, inflate } from "pako";
1
 import { deflate, inflate } from "pako";
2
+import { encryptData, decryptData } from "./encryption";
2
 
3
 
3
 // -----------------------------------------------------------------------------
4
 // -----------------------------------------------------------------------------
4
 // byte (binary) strings
5
 // byte (binary) strings
5
 // -----------------------------------------------------------------------------
6
 // -----------------------------------------------------------------------------
6
 
7
 
7
 // fast, Buffer-compatible implem
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
   return new Promise((resolve, reject) => {
12
   return new Promise((resolve, reject) => {
10
     const blob =
13
     const blob =
11
       typeof data === "string"
14
       typeof data === "string"
12
         ? new Blob([new TextEncoder().encode(data)])
15
         ? new Blob([new TextEncoder().encode(data)])
13
-        : new Blob([data]);
16
+        : new Blob([data instanceof Uint8Array ? data : new Uint8Array(data)]);
14
     const reader = new FileReader();
17
     const reader = new FileReader();
15
     reader.onload = (event) => {
18
     reader.onload = (event) => {
16
       if (!event.target || typeof event.target.result !== "string") {
19
       if (!event.target || typeof event.target.result !== "string") {
44
  *  due to reencoding
47
  *  due to reencoding
45
  */
48
  */
46
 export const stringToBase64 = async (str: string, isByteString = false) => {
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
 // async to align with stringToBase64
53
 // async to align with stringToBase64
51
 export const base64ToString = async (base64: string, isByteString = false) => {
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
 
119
 
115
   return decoded;
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 파일 보기

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 파일 보기

10
 import { debounce } from "../utils";
10
 import { debounce } from "../utils";
11
 
11
 
12
 type FILE_EXTENSION =
12
 type FILE_EXTENSION =
13
+  | "gif"
13
   | "jpg"
14
   | "jpg"
14
   | "png"
15
   | "png"
15
   | "svg"
16
   | "svg"
17
   | "excalidraw"
18
   | "excalidraw"
18
   | "excalidrawlib";
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
 const INPUT_CHANGE_INTERVAL_MS = 500;
21
 const INPUT_CHANGE_INTERVAL_MS = 500;
30
 
22
 
31
 export const fileOpen = <M extends boolean | undefined = false>(opts: {
23
 export const fileOpen = <M extends boolean | undefined = false>(opts: {
41
     : FileWithHandle[];
33
     : FileWithHandle[];
42
 
34
 
43
   const mimeTypes = opts.extensions?.reduce((mimeTypes, type) => {
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
     return mimeTypes;
38
     return mimeTypes;
47
   }, [] as string[]);
39
   }, [] as string[]);

+ 1
- 1
src/data/image.ts 파일 보기

57
   // insert metadata before last chunk (iEND)
57
   // insert metadata before last chunk (iEND)
58
   chunks.splice(-1, 0, metadataChunk);
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
 export const decodePngMetadata = async (blob: Blob) => {
63
 export const decodePngMetadata = async (blob: Blob) => {

+ 18
- 13
src/data/index.ts 파일 보기

2
   copyBlobToClipboardAsPng,
2
   copyBlobToClipboardAsPng,
3
   copyTextToSystemClipboard,
3
   copyTextToSystemClipboard,
4
 } from "../clipboard";
4
 } from "../clipboard";
5
-import { DEFAULT_EXPORT_PADDING } from "../constants";
5
+import { DEFAULT_EXPORT_PADDING, MIME_TYPES } from "../constants";
6
 import { NonDeletedExcalidrawElement } from "../element/types";
6
 import { NonDeletedExcalidrawElement } from "../element/types";
7
 import { t } from "../i18n";
7
 import { t } from "../i18n";
8
 import { exportToCanvas, exportToSvg } from "../scene/export";
8
 import { exportToCanvas, exportToSvg } from "../scene/export";
9
 import { ExportType } from "../scene/types";
9
 import { ExportType } from "../scene/types";
10
-import { AppState } from "../types";
10
+import { AppState, BinaryFiles } from "../types";
11
 import { canvasToBlob } from "./blob";
11
 import { canvasToBlob } from "./blob";
12
 import { fileSave, FileSystemHandle } from "./filesystem";
12
 import { fileSave, FileSystemHandle } from "./filesystem";
13
 import { serializeAsJSON } from "./json";
13
 import { serializeAsJSON } from "./json";
19
   type: ExportType,
19
   type: ExportType,
20
   elements: readonly NonDeletedExcalidrawElement[],
20
   elements: readonly NonDeletedExcalidrawElement[],
21
   appState: AppState,
21
   appState: AppState,
22
+  files: BinaryFiles,
22
   {
23
   {
23
     exportBackground,
24
     exportBackground,
24
     exportPadding = DEFAULT_EXPORT_PADDING,
25
     exportPadding = DEFAULT_EXPORT_PADDING,
37
     throw new Error(t("alerts.cannotExportEmptyCanvas"));
38
     throw new Error(t("alerts.cannotExportEmptyCanvas"));
38
   }
39
   }
39
   if (type === "svg" || type === "clipboard-svg") {
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
     if (type === "svg") {
53
     if (type === "svg") {
49
       return await fileSave(
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
           name,
57
           name,
53
           extension: "svg",
58
           extension: "svg",
60
     }
65
     }
61
   }
66
   }
62
 
67
 
63
-  const tempCanvas = exportToCanvas(elements, appState, {
68
+  const tempCanvas = await exportToCanvas(elements, appState, files, {
64
     exportBackground,
69
     exportBackground,
65
     viewBackgroundColor,
70
     viewBackgroundColor,
66
     exportPadding,
71
     exportPadding,
76
         await import(/* webpackChunkName: "image" */ "./image")
81
         await import(/* webpackChunkName: "image" */ "./image")
77
       ).encodePngMetadata({
82
       ).encodePngMetadata({
78
         blob,
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 파일 보기

1
 import { fileOpen, fileSave } from "./filesystem";
1
 import { fileOpen, fileSave } from "./filesystem";
2
-import { cleanAppStateForExport } from "../appState";
2
+import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
3
 import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
3
 import { EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES } from "../constants";
4
-import { clearElementsForExport } from "../element";
4
+import { clearElementsForDatabase, clearElementsForExport } from "../element";
5
 import { ExcalidrawElement } from "../element/types";
5
 import { ExcalidrawElement } from "../element/types";
6
-import { AppState } from "../types";
6
+import { AppState, BinaryFiles } from "../types";
7
 import { isImageFileHandle, loadFromBlob } from "./blob";
7
 import { isImageFileHandle, loadFromBlob } from "./blob";
8
 
8
 
9
 import {
9
 import {
13
 } from "./types";
13
 } from "./types";
14
 import Library from "./library";
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
 export const serializeAsJSON = (
37
 export const serializeAsJSON = (
17
   elements: readonly ExcalidrawElement[],
38
   elements: readonly ExcalidrawElement[],
18
   appState: Partial<AppState>,
39
   appState: Partial<AppState>,
40
+  files: BinaryFiles,
41
+  type: "local" | "database",
19
 ): string => {
42
 ): string => {
20
   const data: ExportedDataState = {
43
   const data: ExportedDataState = {
21
     type: EXPORT_DATA_TYPES.excalidraw,
44
     type: EXPORT_DATA_TYPES.excalidraw,
22
     version: 2,
45
     version: 2,
23
     source: EXPORT_SOURCE,
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
   return JSON.stringify(data, null, 2);
62
   return JSON.stringify(data, null, 2);
31
 export const saveAsJSON = async (
65
 export const saveAsJSON = async (
32
   elements: readonly ExcalidrawElement[],
66
   elements: readonly ExcalidrawElement[],
33
   appState: AppState,
67
   appState: AppState,
68
+  files: BinaryFiles,
34
 ) => {
69
 ) => {
35
-  const serialized = serializeAsJSON(elements, appState);
70
+  const serialized = serializeAsJSON(elements, appState, files, "local");
36
   const blob = new Blob([serialized], {
71
   const blob = new Blob([serialized], {
37
     type: MIME_TYPES.excalidraw,
72
     type: MIME_TYPES.excalidraw,
38
   });
73
   });
56
     description: "Excalidraw files",
91
     description: "Excalidraw files",
57
     // ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
92
     // ToDo: Be over-permissive until https://bugs.webkit.org/show_bug.cgi?id=34442
58
     // gets resolved. Else, iOS users cannot open `.excalidraw` files.
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
   return loadFromBlob(blob, localAppState, localElements);
96
   return loadFromBlob(blob, localAppState, localElements);
70
 };
97
 };

+ 3
- 1
src/data/resave.ts 파일 보기

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

+ 23
- 11
src/data/restore.ts 파일 보기

3
   ExcalidrawSelectionElement,
3
   ExcalidrawSelectionElement,
4
   FontFamilyValues,
4
   FontFamilyValues,
5
 } from "../element/types";
5
 } from "../element/types";
6
-import { AppState, NormalizedZoomValue } from "../types";
6
+import { AppState, BinaryFiles, NormalizedZoomValue } from "../types";
7
 import { ImportedDataState } from "./types";
7
 import { ImportedDataState } from "./types";
8
 import {
8
 import {
9
   getElementMap,
9
   getElementMap,
37
   diamond: true,
37
   diamond: true,
38
   ellipse: true,
38
   ellipse: true,
39
   line: true,
39
   line: true,
40
+  image: true,
40
   arrow: true,
41
   arrow: true,
41
   freedraw: true,
42
   freedraw: true,
42
 };
43
 };
44
 export type RestoredDataState = {
45
 export type RestoredDataState = {
45
   elements: ExcalidrawElement[];
46
   elements: ExcalidrawElement[];
46
   appState: RestoredAppState;
47
   appState: RestoredAppState;
48
+  files: BinaryFiles;
47
 };
49
 };
48
 
50
 
49
 const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
51
 const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
57
 
59
 
58
 const restoreElementWithProperties = <
60
 const restoreElementWithProperties = <
59
   T extends ExcalidrawElement,
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
   element: Required<T>,
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
 ): T => {
72
 ): T => {
68
   const base: Pick<T, keyof ExcalidrawElement> = {
73
   const base: Pick<T, keyof ExcalidrawElement> = {
69
-    type: (extra as Partial<T>).type || element.type,
74
+    type: extra.type || element.type,
70
     // all elements must have version > 0 so getSceneVersion() will pick up
75
     // all elements must have version > 0 so getSceneVersion() will pick up
71
     // newly added elements
76
     // newly added elements
72
     version: element.version || 1,
77
     version: element.version || 1,
79
     roughness: element.roughness ?? 1,
84
     roughness: element.roughness ?? 1,
80
     opacity: element.opacity == null ? 100 : element.opacity,
85
     opacity: element.opacity == null ? 100 : element.opacity,
81
     angle: element.angle || 0,
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
     strokeColor: element.strokeColor,
89
     strokeColor: element.strokeColor,
85
     backgroundColor: element.backgroundColor,
90
     backgroundColor: element.backgroundColor,
86
     width: element.width || 0,
91
     width: element.width || 0,
102
 
107
 
103
 const restoreElement = (
108
 const restoreElement = (
104
   element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
109
   element: Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
105
-): typeof element => {
110
+): typeof element | null => {
106
   switch (element.type) {
111
   switch (element.type) {
107
     case "text":
112
     case "text":
108
       let fontSize = element.fontSize;
113
       let fontSize = element.fontSize;
131
         pressures: element.pressures,
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
     case "line":
145
     case "line":
135
     // @ts-ignore LEGACY type
146
     // @ts-ignore LEGACY type
136
     // eslint-disable-next-line no-fallthrough
147
     // eslint-disable-next-line no-fallthrough
194
     // filtering out selection, which is legacy, no longer kept in elements,
205
     // filtering out selection, which is legacy, no longer kept in elements,
195
     // and causing issues if retained
206
     // and causing issues if retained
196
     if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
207
     if (element.type !== "selection" && !isInvisiblySmallElement(element)) {
197
-      let migratedElement: ExcalidrawElement = restoreElement(element);
208
+      let migratedElement: ExcalidrawElement | null = restoreElement(element);
198
       if (migratedElement) {
209
       if (migratedElement) {
199
         const localElement = localElementsMap?.[element.id];
210
         const localElement = localElementsMap?.[element.id];
200
         if (localElement && localElement.version > migratedElement.version) {
211
         if (localElement && localElement.version > migratedElement.version) {
260
   return {
271
   return {
261
     elements: restoreElements(data?.elements, localElements),
272
     elements: restoreElements(data?.elements, localElements),
262
     appState: restoreAppState(data?.appState, localAppState || null),
273
     appState: restoreAppState(data?.appState, localAppState || null),
274
+    files: data?.files || {},
263
   };
275
   };
264
 };
276
 };

+ 3
- 1
src/data/types.ts 파일 보기

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

+ 13
- 3
src/element/collision.ts 파일 보기

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

+ 18
- 11
src/element/dragElements.ts 파일 보기

62
   y: number,
62
   y: number,
63
   width: number,
63
   width: number,
64
   height: number,
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
   let newX = x < originX ? originX - width : originX;
87
   let newX = x < originX ? originX - width : originX;
81
   let newY = y < originY ? originY - height : originY;
88
   let newY = y < originY ? originY - height : originY;
82
 
89
 
83
-  if (isResizeCenterPoint) {
90
+  if (shouldResizeFromCenter) {
84
     width += width;
91
     width += width;
85
     height += height;
92
     height += height;
86
     newX = originX - width / 2;
93
     newX = originX - width / 2;

+ 111
- 0
src/element/image.ts 파일 보기

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 파일 보기

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

+ 26
- 10
src/element/mutateElement.ts 파일 보기

17
 export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
17
 export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
18
   element: TElement,
18
   element: TElement,
19
   updates: ElementUpdate<TElement>,
19
   updates: ElementUpdate<TElement>,
20
-) => {
20
+  informMutation = true,
21
+): TElement => {
21
   let didChange = false;
22
   let didChange = false;
22
 
23
 
23
   // casting to any because can't use `in` operator
24
   // casting to any because can't use `in` operator
24
   // (see https://github.com/microsoft/TypeScript/issues/21732)
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
   if (typeof points !== "undefined") {
28
   if (typeof points !== "undefined") {
28
     updates = { ...getSizeFromPoints(points), ...updates };
29
     updates = { ...getSizeFromPoints(points), ...updates };
33
     if (typeof value !== "undefined") {
34
     if (typeof value !== "undefined") {
34
       if (
35
       if (
35
         (element as any)[key] === value &&
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
         continue;
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
         const prevPoints = (element as any)[key];
54
         const prevPoints = (element as any)[key];
44
         const nextPoints = value;
55
         const nextPoints = value;
45
         if (prevPoints.length === nextPoints.length) {
56
         if (prevPoints.length === nextPoints.length) {
66
       didChange = true;
77
       didChange = true;
67
     }
78
     }
68
   }
79
   }
69
-
70
   if (!didChange) {
80
   if (!didChange) {
71
-    return;
81
+    return element;
72
   }
82
   }
73
 
83
 
74
   if (
84
   if (
75
     typeof updates.height !== "undefined" ||
85
     typeof updates.height !== "undefined" ||
76
     typeof updates.width !== "undefined" ||
86
     typeof updates.width !== "undefined" ||
87
+    typeof fileId != "undefined" ||
77
     typeof points !== "undefined"
88
     typeof points !== "undefined"
78
   ) {
89
   ) {
79
     invalidateShapeForElement(element);
90
     invalidateShapeForElement(element);
81
 
92
 
82
   element.version++;
93
   element.version++;
83
   element.versionNonce = randomInteger();
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
 export const newElementWith = <TElement extends ExcalidrawElement>(
103
 export const newElementWith = <TElement extends ExcalidrawElement>(
94
     if (typeof value !== "undefined") {
110
     if (typeof value !== "undefined") {
95
       if (
111
       if (
96
         (element as any)[key] === value &&
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
         continue;
116
         continue;
101
       }
117
       }

+ 17
- 0
src/element/newElement.ts 파일 보기

1
 import {
1
 import {
2
   ExcalidrawElement,
2
   ExcalidrawElement,
3
+  ExcalidrawImageElement,
3
   ExcalidrawTextElement,
4
   ExcalidrawTextElement,
4
   ExcalidrawLinearElement,
5
   ExcalidrawLinearElement,
5
   ExcalidrawGenericElement,
6
   ExcalidrawGenericElement,
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
 // Simplified deep clone for the purpose of cloning ExcalidrawElement only
268
 // Simplified deep clone for the purpose of cloning ExcalidrawElement only
252
 // (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
269
 // (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
253
 //
270
 //

+ 40
- 28
src/element/resizeElements.ts 파일 보기

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

+ 14
- 0
src/element/typeChecks.ts 파일 보기

5
   ExcalidrawBindableElement,
5
   ExcalidrawBindableElement,
6
   ExcalidrawGenericElement,
6
   ExcalidrawGenericElement,
7
   ExcalidrawFreeDrawElement,
7
   ExcalidrawFreeDrawElement,
8
+  InitializedExcalidrawImageElement,
9
+  ExcalidrawImageElement,
8
 } from "./types";
10
 } from "./types";
9
 
11
 
10
 export const isGenericElement = (
12
 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
 export const isTextElement = (
36
 export const isTextElement = (
23
   element: ExcalidrawElement | null,
37
   element: ExcalidrawElement | null,
24
 ): element is ExcalidrawTextElement => {
38
 ): element is ExcalidrawTextElement => {

+ 22
- 3
src/element/types.ts 파일 보기

63
   type: "ellipse";
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
  * These are elements that don't have any additional properties.
82
  * These are elements that don't have any additional properties.
68
  */
83
  */
81
   | ExcalidrawGenericElement
96
   | ExcalidrawGenericElement
82
   | ExcalidrawTextElement
97
   | ExcalidrawTextElement
83
   | ExcalidrawLinearElement
98
   | ExcalidrawLinearElement
84
-  | ExcalidrawFreeDrawElement;
99
+  | ExcalidrawFreeDrawElement
100
+  | ExcalidrawImageElement;
85
 
101
 
86
 export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
102
 export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
87
-  isDeleted: false;
103
+  isDeleted: boolean;
88
 };
104
 };
89
 
105
 
90
 export type NonDeletedExcalidrawElement = NonDeleted<ExcalidrawElement>;
106
 export type NonDeletedExcalidrawElement = NonDeleted<ExcalidrawElement>;
104
   | ExcalidrawRectangleElement
120
   | ExcalidrawRectangleElement
105
   | ExcalidrawDiamondElement
121
   | ExcalidrawDiamondElement
106
   | ExcalidrawEllipseElement
122
   | ExcalidrawEllipseElement
107
-  | ExcalidrawTextElement;
123
+  | ExcalidrawTextElement
124
+  | ExcalidrawImageElement;
108
 
125
 
109
 export type PointBinding = {
126
 export type PointBinding = {
110
   elementId: ExcalidrawBindableElement["id"];
127
   elementId: ExcalidrawBindableElement["id"];
133
     simulatePressure: boolean;
150
     simulatePressure: boolean;
134
     lastCommittedPoint: Point | null;
151
     lastCommittedPoint: Point | null;
135
   }>;
152
   }>;
153
+
154
+export type FileId = string & { _brand: "FileId" };

+ 11
- 0
src/excalidraw-app/app_constants.ts 파일 보기

1
 // time constants (ms)
1
 // time constants (ms)
2
 export const SAVE_TO_LOCAL_STORAGE_TIMEOUT = 300;
2
 export const SAVE_TO_LOCAL_STORAGE_TIMEOUT = 300;
3
 export const INITIAL_SCENE_UPDATE_TIMEOUT = 5000;
3
 export const INITIAL_SCENE_UPDATE_TIMEOUT = 5000;
4
+export const FILE_UPLOAD_TIMEOUT = 300;
5
+export const LOAD_IMAGES_TIMEOUT = 500;
4
 export const SYNC_FULL_SCENE_INTERVAL_MS = 20000;
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
 export const BROADCAST = {
12
 export const BROADCAST = {
7
   SERVER_VOLATILE: "server-volatile-broadcast",
13
   SERVER_VOLATILE: "server-volatile-broadcast",
8
   SERVER: "server-broadcast",
14
   SERVER: "server-broadcast",
12
   INIT = "SCENE_INIT",
18
   INIT = "SCENE_INIT",
13
   UPDATE = "SCENE_UPDATE",
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 파일 보기

4
 import { ErrorDialog } from "../../components/ErrorDialog";
4
 import { ErrorDialog } from "../../components/ErrorDialog";
5
 import { APP_NAME, ENV, EVENT } from "../../constants";
5
 import { APP_NAME, ENV, EVENT } from "../../constants";
6
 import { ImportedDataState } from "../../data/types";
6
 import { ImportedDataState } from "../../data/types";
7
-import { ExcalidrawElement } from "../../element/types";
7
+import {
8
+  ExcalidrawElement,
9
+  InitializedExcalidrawImageElement,
10
+} from "../../element/types";
8
 import {
11
 import {
9
   getElementMap,
12
   getElementMap,
10
   getSceneVersion,
13
   getSceneVersion,
11
 } from "../../packages/excalidraw/index";
14
 } from "../../packages/excalidraw/index";
12
 import { Collaborator, Gesture } from "../../types";
15
 import { Collaborator, Gesture } from "../../types";
13
-import { resolvablePromise, withBatchedUpdates } from "../../utils";
14
 import {
16
 import {
17
+  preventUnload,
18
+  resolvablePromise,
19
+  withBatchedUpdates,
20
+} from "../../utils";
21
+import {
22
+  FILE_UPLOAD_MAX_BYTES,
23
+  FIREBASE_STORAGE_PREFIXES,
15
   INITIAL_SCENE_UPDATE_TIMEOUT,
24
   INITIAL_SCENE_UPDATE_TIMEOUT,
25
+  LOAD_IMAGES_TIMEOUT,
16
   SCENE,
26
   SCENE,
17
   SYNC_FULL_SCENE_INTERVAL_MS,
27
   SYNC_FULL_SCENE_INTERVAL_MS,
18
 } from "../app_constants";
28
 } from "../app_constants";
25
 } from "../data";
35
 } from "../data";
26
 import {
36
 import {
27
   isSavedToFirebase,
37
   isSavedToFirebase,
38
+  loadFilesFromFirebase,
28
   loadFromFirebase,
39
   loadFromFirebase,
40
+  saveFilesToFirebase,
29
   saveToFirebase,
41
   saveToFirebase,
30
 } from "../data/firebase";
42
 } from "../data/firebase";
31
 import {
43
 import {
41
 import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
53
 import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
42
 import { trackEvent } from "../../analytics";
54
 import { trackEvent } from "../../analytics";
43
 import { isInvisiblySmallElement } from "../../element";
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
 interface CollabState {
68
 interface CollabState {
46
   modalIsShown: boolean;
69
   modalIsShown: boolean;
61
   initializeSocketClient: CollabInstance["initializeSocketClient"];
84
   initializeSocketClient: CollabInstance["initializeSocketClient"];
62
   onCollabButtonClick: CollabInstance["onCollabButtonClick"];
85
   onCollabButtonClick: CollabInstance["onCollabButtonClick"];
63
   broadcastElements: CollabInstance["broadcastElements"];
86
   broadcastElements: CollabInstance["broadcastElements"];
87
+  fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
64
 }
88
 }
65
 
89
 
66
 type ReconciledElements = readonly ExcalidrawElement[] & {
90
 type ReconciledElements = readonly ExcalidrawElement[] & {
69
 
93
 
70
 interface Props {
94
 interface Props {
71
   excalidrawAPI: ExcalidrawImperativeAPI;
95
   excalidrawAPI: ExcalidrawImperativeAPI;
96
+  onRoomClose?: () => void;
72
 }
97
 }
73
 
98
 
74
 const {
99
 const {
81
 
106
 
82
 class CollabWrapper extends PureComponent<Props, CollabState> {
107
 class CollabWrapper extends PureComponent<Props, CollabState> {
83
   portal: Portal;
108
   portal: Portal;
109
+  fileManager: FileManager;
84
   excalidrawAPI: Props["excalidrawAPI"];
110
   excalidrawAPI: Props["excalidrawAPI"];
85
   isCollaborating: boolean = false;
111
   isCollaborating: boolean = false;
86
   activeIntervalId: number | null;
112
   activeIntervalId: number | null;
87
   idleTimeoutId: number | null;
113
   idleTimeoutId: number | null;
88
 
114
 
89
-  private socketInitializationTimer?: NodeJS.Timeout;
115
+  private socketInitializationTimer?: number;
90
   private lastBroadcastedOrReceivedSceneVersion: number = -1;
116
   private lastBroadcastedOrReceivedSceneVersion: number = -1;
91
   private collaborators = new Map<string, Collaborator>();
117
   private collaborators = new Map<string, Collaborator>();
92
 
118
 
100
       activeRoomLink: "",
126
       activeRoomLink: "",
101
     };
127
     };
102
     this.portal = new Portal(this);
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
     this.excalidrawAPI = props.excalidrawAPI;
154
     this.excalidrawAPI = props.excalidrawAPI;
104
     this.activeIntervalId = null;
155
     this.activeIntervalId = null;
105
     this.idleTimeoutId = null;
156
     this.idleTimeoutId = null;
152
 
203
 
153
     if (
204
     if (
154
       this.isCollaborating &&
205
       this.isCollaborating &&
155
-      !isSavedToFirebase(this.portal, syncableElements)
206
+      (this.fileManager.shouldPreventUnload(syncableElements) ||
207
+        !isSavedToFirebase(this.portal, syncableElements))
156
     ) {
208
     ) {
157
       // this won't run in time if user decides to leave the site, but
209
       // this won't run in time if user decides to leave the site, but
158
       //  the purpose is to run in immediately after user decides to stay
210
       //  the purpose is to run in immediately after user decides to stay
159
       this.saveCollabRoomToFirebase(syncableElements);
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
     if (this.isCollaborating || this.portal.roomId) {
216
     if (this.isCollaborating || this.portal.roomId) {
199
       window.history.pushState({}, APP_NAME, window.location.origin);
249
       window.history.pushState({}, APP_NAME, window.location.origin);
200
       this.destroySocketClient();
250
       this.destroySocketClient();
201
       trackEvent("share", "room closed");
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
       });
279
       });
214
       this.isCollaborating = false;
280
       this.isCollaborating = false;
215
     }
281
     }
282
+    this.lastBroadcastedOrReceivedSceneVersion = -1;
216
     this.portal.close();
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
   private initializeSocketClient = async (
304
   private initializeSocketClient = async (
267
         console.error(error);
352
         console.error(error);
268
       }
353
       }
269
     } else {
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
       // remove deleted elements from elements array & history to ensure we don't
365
       // remove deleted elements from elements array & history to ensure we don't
272
       // expose potentially sensitive user data in case user manually deletes
366
       // expose potentially sensitive user data in case user manually deletes
273
       // existing elements (or clears scene), which would otherwise be persisted
367
       // existing elements (or clears scene), which would otherwise be persisted
277
         elements,
371
         elements,
278
         commitToHistory: true,
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
     // fallback in case you're not alone in the room but still don't receive
381
     // fallback in case you're not alone in the room but still don't receive
283
     // initial SCENE_UPDATE message
382
     // initial SCENE_UPDATE message
284
-    this.socketInitializationTimer = setTimeout(() => {
383
+    this.socketInitializationTimer = window.setTimeout(() => {
285
       this.initializeSocket();
384
       this.initializeSocket();
286
       scenePromise.resolve(null);
385
       scenePromise.resolve(null);
287
     }, INITIAL_SCENE_UPDATE_TIMEOUT);
386
     }, INITIAL_SCENE_UPDATE_TIMEOUT);
446
     return newElements as ReconciledElements;
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
   private handleRemoteSceneUpdate = (
565
   private handleRemoteSceneUpdate = (
450
     elements: ReconciledElements,
566
     elements: ReconciledElements,
451
     { init = false }: { init?: boolean } = {},
567
     { init = false }: { init?: boolean } = {},
460
     // undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
576
     // undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
461
     // right now we think this is the right tradeoff.
577
     // right now we think this is the right tradeoff.
462
     this.excalidrawAPI.history.clear();
578
     this.excalidrawAPI.history.clear();
579
+
580
+    this.loadImageFiles();
463
   };
581
   };
464
 
582
 
465
   private onPointerMove = () => {
583
   private onPointerMove = () => {
622
     this.contextValue.initializeSocketClient = this.initializeSocketClient;
740
     this.contextValue.initializeSocketClient = this.initializeSocketClient;
623
     this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
741
     this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
624
     this.contextValue.broadcastElements = this.broadcastElements;
742
     this.contextValue.broadcastElements = this.broadcastElements;
743
+    this.contextValue.fetchImageFilesFromFirebase = this.fetchImageFilesFromFirebase;
625
     return this.contextValue;
744
     return this.contextValue;
626
   };
745
   };
627
 
746
 

+ 38
- 1
src/excalidraw-app/collab/Portal.tsx 파일 보기

7
 import CollabWrapper from "./CollabWrapper";
7
 import CollabWrapper from "./CollabWrapper";
8
 
8
 
9
 import { ExcalidrawElement } from "../../element/types";
9
 import { ExcalidrawElement } from "../../element/types";
10
-import { BROADCAST, SCENE } from "../app_constants";
10
+import { BROADCAST, FILE_UPLOAD_TIMEOUT, SCENE } from "../app_constants";
11
 import { UserIdleState } from "../../types";
11
 import { UserIdleState } from "../../types";
12
 import { trackEvent } from "../../analytics";
12
 import { trackEvent } from "../../analytics";
13
+import { throttle } from "lodash";
14
+import { mutateElement } from "../../element/mutateElement";
13
 
15
 
14
 class Portal {
16
 class Portal {
15
   collab: CollabWrapper;
17
   collab: CollabWrapper;
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
   broadcastScene = async (
125
   broadcastScene = async (
91
     sceneType: SCENE.INIT | SCENE.UPDATE,
126
     sceneType: SCENE.INIT | SCENE.UPDATE,
92
     syncableElements: ExcalidrawElement[],
127
     syncableElements: ExcalidrawElement[],
126
       data as SocketUpdateData,
161
       data as SocketUpdateData,
127
     );
162
     );
128
 
163
 
164
+    this.queueFileUpload();
165
+
129
     if (syncAll && this.collab.isCollaborating) {
166
     if (syncAll && this.collab.isCollaborating) {
130
       await Promise.all([
167
       await Promise.all([
131
         broadcastPromise,
168
         broadcastPromise,

+ 45
- 33
src/excalidraw-app/components/ExportToExcalidrawPlus.tsx 파일 보기

2
 import { Card } from "../../components/Card";
2
 import { Card } from "../../components/Card";
3
 import { ToolButton } from "../../components/ToolButton";
3
 import { ToolButton } from "../../components/ToolButton";
4
 import { serializeAsJSON } from "../../data/json";
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
 import { nanoid } from "nanoid";
8
 import { nanoid } from "nanoid";
10
 import { t } from "../../i18n";
9
 import { t } from "../../i18n";
11
 import { excalidrawPlusIcon } from "./icons";
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
 const exportToExcalidrawPlus = async (
17
 const exportToExcalidrawPlus = async (
33
   elements: readonly NonDeletedExcalidrawElement[],
18
   elements: readonly NonDeletedExcalidrawElement[],
34
   appState: AppState,
19
   appState: AppState,
20
+  files: BinaryFiles,
35
 ) => {
21
 ) => {
36
   const firebase = await loadFirebaseStorage();
22
   const firebase = await loadFirebaseStorage();
37
 
23
 
38
   const id = `${nanoid(12)}`;
24
   const id = `${nanoid(12)}`;
39
 
25
 
40
-  const key = (await generateEncryptionKey())!;
26
+  const encryptionKey = (await generateEncryptionKey())!;
41
   const encryptedData = await encryptData(
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
   await firebase
39
   await firebase
51
     .storage()
40
     .storage()
52
     .ref(`/migrations/scenes/${id}`)
41
     .ref(`/migrations/scenes/${id}`)
53
     .put(blob, {
42
     .put(blob, {
54
       customMetadata: {
43
       customMetadata: {
55
-        data: JSON.stringify({ version: 1, name: appState.name }),
44
+        data: JSON.stringify({ version: 2, name: appState.name }),
56
         created: Date.now().toString(),
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
 export const ExportToExcalidrawPlus: React.FC<{
74
 export const ExportToExcalidrawPlus: React.FC<{
64
   elements: readonly NonDeletedExcalidrawElement[];
75
   elements: readonly NonDeletedExcalidrawElement[];
65
   appState: AppState;
76
   appState: AppState;
77
+  files: BinaryFiles;
66
   onError: (error: Error) => void;
78
   onError: (error: Error) => void;
67
-}> = ({ elements, appState, onError }) => {
79
+}> = ({ elements, appState, files, onError }) => {
68
   return (
80
   return (
69
     <Card color="indigo">
81
     <Card color="indigo">
70
       <div className="Card-icon">{excalidrawPlusIcon}</div>
82
       <div className="Card-icon">{excalidrawPlusIcon}</div>
80
         showAriaLabel={true}
92
         showAriaLabel={true}
81
         onClick={async () => {
93
         onClick={async () => {
82
           try {
94
           try {
83
-            await exportToExcalidrawPlus(elements, appState);
95
+            await exportToExcalidrawPlus(elements, appState, files);
84
           } catch (error) {
96
           } catch (error) {
85
             console.error(error);
97
             console.error(error);
86
             onError(new Error(t("exportDialog.excalidrawplus_exportError")));
98
             onError(new Error(t("exportDialog.excalidrawplus_exportError")));

+ 249
- 0
src/excalidraw-app/data/FileManager.ts 파일 보기

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 파일 보기

1
-import { getImportedKey } from "../data";
2
-import { createIV } from "./index";
3
-import { ExcalidrawElement } from "../../element/types";
1
+import { ExcalidrawElement, FileId } from "../../element/types";
4
 import { getSceneVersion } from "../../element";
2
 import { getSceneVersion } from "../../element";
5
 import Portal from "../collab/Portal";
3
 import Portal from "../collab/Portal";
6
 import { restoreElements } from "../../data/restore";
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
 // private
11
 // private
9
 // -----------------------------------------------------------------------------
12
 // -----------------------------------------------------------------------------
10
 
13
 
14
+const FIREBASE_CONFIG = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
15
+
11
 let firebasePromise: Promise<
16
 let firebasePromise: Promise<
12
   typeof import("firebase/app").default
17
   typeof import("firebase/app").default
13
 > | null = null;
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
 const _loadFirebase = async () => {
24
 const _loadFirebase = async () => {
18
   const firebase = (
25
   const firebase = (
19
     await import(/* webpackChunkName: "firebase" */ "firebase/app")
26
     await import(/* webpackChunkName: "firebase" */ "firebase/app")
20
   ).default;
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
   return firebase;
44
   return firebase;
26
 };
45
 };
42
     firestorePromise = import(
61
     firestorePromise = import(
43
       /* webpackChunkName: "firestore" */ "firebase/firestore"
62
       /* webpackChunkName: "firestore" */ "firebase/firestore"
44
     );
63
     );
64
+  }
65
+  if (firestorePromise !== true) {
45
     await firestorePromise;
66
     await firestorePromise;
67
+    firestorePromise = true;
46
   }
68
   }
47
   return firebase;
69
   return firebase;
48
 };
70
 };
49
 
71
 
50
 export const loadFirebaseStorage = async () => {
72
 export const loadFirebaseStorage = async () => {
51
   const firebase = await _getFirebase();
73
   const firebase = await _getFirebase();
52
-  if (!firebseStoragePromise) {
53
-    firebseStoragePromise = import(
74
+  if (!firebaseStoragePromise) {
75
+    firebaseStoragePromise = import(
54
       /* webpackChunkName: "storage" */ "firebase/storage"
76
       /* webpackChunkName: "storage" */ "firebase/storage"
55
     );
77
     );
56
-    await firebseStoragePromise;
78
+  }
79
+  if (firebaseStoragePromise !== true) {
80
+    await firebaseStoragePromise;
81
+    firebaseStoragePromise = true;
57
   }
82
   }
58
   return firebase;
83
   return firebase;
59
 };
84
 };
87
 const decryptElements = async (
112
 const decryptElements = async (
88
   key: string,
113
   key: string,
89
   iv: Uint8Array,
114
   iv: Uint8Array,
90
-  ciphertext: ArrayBuffer,
115
+  ciphertext: ArrayBuffer | Uint8Array,
91
 ): Promise<readonly ExcalidrawElement[]> => {
116
 ): Promise<readonly ExcalidrawElement[]> => {
92
   const importedKey = await getImportedKey(key, "decrypt");
117
   const importedKey = await getImportedKey(key, "decrypt");
93
   const decrypted = await window.crypto.subtle.decrypt(
118
   const decrypted = await window.crypto.subtle.decrypt(
100
   );
125
   );
101
 
126
 
102
   const decodedData = new TextDecoder("utf-8").decode(
127
   const decodedData = new TextDecoder("utf-8").decode(
103
-    new Uint8Array(decrypted) as any,
128
+    new Uint8Array(decrypted),
104
   );
129
   );
105
   return JSON.parse(decodedData);
130
   return JSON.parse(decodedData);
106
 };
131
 };
113
 ): boolean => {
138
 ): boolean => {
114
   if (portal.socket && portal.roomId && portal.roomKey) {
139
   if (portal.socket && portal.roomId && portal.roomKey) {
115
     const sceneVersion = getSceneVersion(elements);
140
     const sceneVersion = getSceneVersion(elements);
141
+
116
     return firebaseSceneVersionCache.get(portal.socket) === sceneVersion;
142
     return firebaseSceneVersionCache.get(portal.socket) === sceneVersion;
117
   }
143
   }
118
   // if no room exists, consider the room saved so that we don't unnecessarily
144
   // if no room exists, consider the room saved so that we don't unnecessarily
120
   return true;
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
 export const saveToFirebase = async (
185
 export const saveToFirebase = async (
124
   portal: Portal,
186
   portal: Portal,
125
   elements: readonly ExcalidrawElement[],
187
   elements: readonly ExcalidrawElement[],
198
 
260
 
199
   return restoreElements(elements, null);
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 파일 보기

1
+import {
2
+  createIV,
3
+  generateEncryptionKey,
4
+  getImportedKey,
5
+  IV_LENGTH_BYTES,
6
+} from "../../data/encryption";
1
 import { serializeAsJSON } from "../../data/json";
7
 import { serializeAsJSON } from "../../data/json";
2
 import { restore } from "../../data/restore";
8
 import { restore } from "../../data/restore";
3
 import { ImportedDataState } from "../../data/types";
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
 import { t } from "../../i18n";
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
 const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
23
 const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
9
 
24
 
17
   return Array.from(arr, byteToHex).join("");
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
 export const SOCKET_SERVER = process.env.REACT_APP_SOCKET_SERVER_URL;
35
 export const SOCKET_SERVER = process.env.REACT_APP_SOCKET_SERVER_URL;
33
 
36
 
34
 export type EncryptedData = {
37
 export type EncryptedData = {
79
   _brand: "socketUpdateData";
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
 export const encryptAESGEM = async (
85
 export const encryptAESGEM = async (
90
   data: Uint8Array,
86
   data: Uint8Array,
91
   key: string,
87
   key: string,
122
     );
118
     );
123
 
119
 
124
     const decodedData = new TextDecoder("utf-8").decode(
120
     const decodedData = new TextDecoder("utf-8").decode(
125
-      new Uint8Array(decrypted) as any,
121
+      new Uint8Array(decrypted),
126
     );
122
     );
127
     return JSON.parse(decodedData);
123
     return JSON.parse(decodedData);
128
   } catch (error) {
124
   } catch (error) {
162
   return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`;
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
 export const decryptImported = async (
161
 export const decryptImported = async (
184
-  iv: ArrayBuffer,
162
+  iv: ArrayBuffer | Uint8Array,
185
   encrypted: ArrayBuffer,
163
   encrypted: ArrayBuffer,
186
   privateKey: string,
164
   privateKey: string,
187
 ): Promise<ArrayBuffer> => {
165
 ): Promise<ArrayBuffer> => {
227
 
205
 
228
       // We need to convert the decrypted array buffer to a string
206
       // We need to convert the decrypted array buffer to a string
229
       const string = new window.TextDecoder("utf-8").decode(
207
       const string = new window.TextDecoder("utf-8").decode(
230
-        new Uint8Array(decrypted) as any,
208
+        new Uint8Array(decrypted),
231
       );
209
       );
232
       data = JSON.parse(string);
210
       data = JSON.parse(string);
233
     } else {
211
     } else {
270
   return {
248
   return {
271
     elements: data.elements,
249
     elements: data.elements,
272
     appState: data.appState,
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
     commitToHistory: false,
255
     commitToHistory: false,
274
   };
256
   };
275
 };
257
 };
277
 export const exportToBackend = async (
259
 export const exportToBackend = async (
278
   elements: readonly ExcalidrawElement[],
260
   elements: readonly ExcalidrawElement[],
279
   appState: AppState,
261
   appState: AppState,
262
+  files: BinaryFiles,
280
 ) => {
263
 ) => {
281
-  const json = serializeAsJSON(elements, appState);
264
+  const json = serializeAsJSON(elements, appState, files, "database");
282
   const encoded = new TextEncoder().encode(json);
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
       name: "AES-GCM",
269
       name: "AES-GCM",
287
       length: 128,
270
       length: 128,
298
       name: "AES-GCM",
281
       name: "AES-GCM",
299
       iv,
282
       iv,
300
     },
283
     },
301
-    key,
284
+    cryptoKey,
302
     encoded,
285
     encoded,
303
   );
286
   );
304
 
287
 
308
 
291
 
309
   // We use jwk encoding to be able to extract just the base64 encoded key.
292
   // We use jwk encoding to be able to extract just the base64 encoded key.
310
   // We will hardcode the rest of the attributes when importing back the key.
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
   try {
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
     const response = await fetch(BACKEND_V2_POST, {
312
     const response = await fetch(BACKEND_V2_POST, {
315
       method: "POST",
313
       method: "POST",
316
       body: payload,
314
       body: payload,
320
       const url = new URL(window.location.href);
318
       const url = new URL(window.location.href);
321
       // We need to store the key (and less importantly the id) as hash instead
319
       // We need to store the key (and less importantly the id) as hash instead
322
       // of queryParam in order to never send it to the server
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
       const urlString = url.toString();
322
       const urlString = url.toString();
323
+
324
+      await saveFilesToFirebase({
325
+        prefix: `/files/shareLinks/${json.id}`,
326
+        files: filesToUpload,
327
+      });
328
+
325
       window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString);
329
       window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString);
326
     } else if (json.error_class === "RequestTooLargeError") {
330
     } else if (json.error_class === "RequestTooLargeError") {
327
       window.alert(t("alerts.couldNotCreateShareableLinkTooBig"));
331
       window.alert(t("alerts.couldNotCreateShareableLinkTooBig"));

+ 259
- 39
src/excalidraw-app/index.tsx 파일 보기

16
 import { ImportedDataState } from "../data/types";
16
 import { ImportedDataState } from "../data/types";
17
 import {
17
 import {
18
   ExcalidrawElement,
18
   ExcalidrawElement,
19
+  FileId,
19
   NonDeletedExcalidrawElement,
20
   NonDeletedExcalidrawElement,
20
 } from "../element/types";
21
 } from "../element/types";
21
 import { useCallbackRefState } from "../hooks/useCallbackRefState";
22
 import { useCallbackRefState } from "../hooks/useCallbackRefState";
24
   defaultLang,
25
   defaultLang,
25
   languages,
26
   languages,
26
 } from "../packages/excalidraw/index";
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
 import {
35
 import {
29
   debounce,
36
   debounce,
30
   getVersion,
37
   getVersion,
38
+  preventUnload,
31
   ResolvablePromise,
39
   ResolvablePromise,
32
   resolvablePromise,
40
   resolvablePromise,
33
 } from "../utils";
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
 import CollabWrapper, {
46
 import CollabWrapper, {
36
   CollabAPI,
47
   CollabAPI,
37
   CollabContext,
48
   CollabContext,
51
 import "./index.scss";
62
 import "./index.scss";
52
 import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
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
 const languageDetector = new LanguageDetector();
123
 const languageDetector = new LanguageDetector();
55
 languageDetector.init({
124
 languageDetector.init({
56
   languageUtils: {
125
   languageUtils: {
61
 });
130
 });
62
 
131
 
63
 const saveDebounced = debounce(
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
   SAVE_TO_LOCAL_STORAGE_TIMEOUT,
148
   SAVE_TO_LOCAL_STORAGE_TIMEOUT,
68
 );
149
 );
73
 
154
 
74
 const initializeScene = async (opts: {
155
 const initializeScene = async (opts: {
75
   collabAPI: CollabAPI;
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
   const searchParams = new URLSearchParams(window.location.search);
163
   const searchParams = new URLSearchParams(window.location.search);
78
   const id = searchParams.get("id");
164
   const id = searchParams.get("id");
79
   const jsonBackendMatch = window.location.hash.match(
165
   const jsonBackendMatch = window.location.hash.match(
140
         !scene.elements.length ||
226
         !scene.elements.length ||
141
         window.confirm(t("alerts.loadSceneOverridePrompt"))
227
         window.confirm(t("alerts.loadSceneOverridePrompt"))
142
       ) {
228
       ) {
143
-        return data;
229
+        return { scene: data, isExternalScene };
144
       }
230
       }
145
     } catch (error) {
231
     } catch (error) {
146
       return {
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
   if (roomLinkData) {
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
   } else if (scene) {
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
 const PlusLinkJSX = (
263
 const PlusLinkJSX = (
207
       return;
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
     const onHashChange = (event: HashChangeEvent) => {
391
     const onHashChange = (event: HashChangeEvent) => {
235
         window.history.replaceState({}, "", event.oldURL);
400
         window.history.replaceState({}, "", event.oldURL);
236
         excalidrawAPI.importLibrary(libraryUrl, hash.get("token"));
401
         excalidrawAPI.importLibrary(libraryUrl, hash.get("token"));
237
       } else {
402
       } else {
238
-        initializeScene({ collabAPI }).then((scene) => {
239
-          if (scene) {
403
+        initializeScene({ collabAPI }).then((data) => {
404
+          loadImages(data);
405
+          if (data.scene) {
240
             excalidrawAPI.updateScene({
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
     };
427
     };
262
   }, [collabAPI, excalidrawAPI]);
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
   useEffect(() => {
447
   useEffect(() => {
265
     languageDetector.cacheUserLanguage(langCode);
448
     languageDetector.cacheUserLanguage(langCode);
266
   }, [langCode]);
449
   }, [langCode]);
268
   const onChange = (
451
   const onChange = (
269
     elements: readonly ExcalidrawElement[],
452
     elements: readonly ExcalidrawElement[],
270
     appState: AppState,
453
     appState: AppState,
454
+    files: BinaryFiles,
271
   ) => {
455
   ) => {
272
     if (collabAPI?.isCollaborating()) {
456
     if (collabAPI?.isCollaborating()) {
273
       collabAPI.broadcastElements(elements);
457
       collabAPI.broadcastElements(elements);
274
     } else {
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
   const onExportToBackend = async (
487
   const onExportToBackend = async (
283
     exportedElements: readonly NonDeletedExcalidrawElement[],
488
     exportedElements: readonly NonDeletedExcalidrawElement[],
284
     appState: AppState,
489
     appState: AppState,
490
+    files: BinaryFiles,
285
     canvas: HTMLCanvasElement | null,
491
     canvas: HTMLCanvasElement | null,
286
   ) => {
492
   ) => {
287
     if (exportedElements.length === 0) {
493
     if (exportedElements.length === 0) {
289
     }
495
     }
290
     if (canvas) {
496
     if (canvas) {
291
       try {
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
       } catch (error) {
508
       } catch (error) {
299
         if (error.name !== "AbortError") {
509
         if (error.name !== "AbortError") {
300
           const { width, height } = canvas;
510
           const { width, height } = canvas;
409
     localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
619
     localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
410
   };
620
   };
411
 
621
 
622
+  const onRoomClose = useCallback(() => {
623
+    localFileStorage.reset();
624
+  }, []);
625
+
412
   return (
626
   return (
413
     <>
627
     <>
414
       <Excalidraw
628
       <Excalidraw
422
           canvasActions: {
636
           canvasActions: {
423
             export: {
637
             export: {
424
               onExportToBackend,
638
               onExportToBackend,
425
-              renderCustomUI: (elements, appState) => {
639
+              renderCustomUI: (elements, appState, files) => {
426
                 return (
640
                 return (
427
                   <ExportToExcalidrawPlus
641
                   <ExportToExcalidrawPlus
428
                     elements={elements}
642
                     elements={elements}
429
                     appState={appState}
643
                     appState={appState}
644
+                    files={files}
430
                     onError={(error) => {
645
                     onError={(error) => {
431
                       excalidrawAPI?.updateScene({
646
                       excalidrawAPI?.updateScene({
432
                         appState: {
647
                         appState: {
449
         onLibraryChange={onLibraryChange}
664
         onLibraryChange={onLibraryChange}
450
         autoFocus={true}
665
         autoFocus={true}
451
       />
666
       />
452
-      {excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
667
+      {excalidrawAPI && (
668
+        <CollabWrapper
669
+          excalidrawAPI={excalidrawAPI}
670
+          onRoomClose={onRoomClose}
671
+        />
672
+      )}
453
       {errorMessage && (
673
       {errorMessage && (
454
         <ErrorDialog
674
         <ErrorDialog
455
           message={errorMessage}
675
           message={errorMessage}

+ 39
- 0
src/global.d.ts 파일 보기

47
 type MarkRequired<T, RK extends keyof T> = Exclude<T, RK> &
47
 type MarkRequired<T, RK extends keyof T> = Exclude<T, RK> &
48
   Required<Pick<T, RK>>;
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
 // PNG encoding/decoding
55
 // PNG encoding/decoding
51
 // -----------------------------------------------------------------------------
56
 // -----------------------------------------------------------------------------
52
 type TEXtChunk = { name: "tEXt"; data: Uint8Array };
57
 type TEXtChunk = { name: "tEXt"; data: Uint8Array };
91
 }
96
 }
92
 
97
 
93
 declare module "*.scss";
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 파일 보기

66
     width: 0,
66
     width: 0,
67
     height: 0,
67
     height: 0,
68
   },
68
   },
69
+  {}, // files
69
   {
70
   {
70
     exportBackground: true,
71
     exportBackground: true,
71
     viewBackgroundColor: "#ffffff",
72
     viewBackgroundColor: "#ffffff",

+ 5
- 5
src/keys.ts 파일 보기

45
   D: "d",
45
   D: "d",
46
   E: "e",
46
   E: "e",
47
   G: "g",
47
   G: "g",
48
+  I: "i",
48
   L: "l",
49
   L: "l",
49
   O: "o",
50
   O: "o",
50
   P: "p",
51
   P: "p",
66
   key === KEYS.ARROW_DOWN ||
67
   key === KEYS.ARROW_DOWN ||
67
   key === KEYS.ARROW_UP;
68
   key === KEYS.ARROW_UP;
68
 
69
 
69
-export const getResizeCenterPointKey = (event: MouseEvent | KeyboardEvent) =>
70
+export const shouldResizeFromCenter = (event: MouseEvent | KeyboardEvent) =>
70
   event.altKey;
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
   event: MouseEvent | KeyboardEvent,
77
   event: MouseEvent | KeyboardEvent,
78
 ) => event.shiftKey;
78
 ) => event.shiftKey;

+ 12
- 2
src/locales/en.json 파일 보기

156
     "errorAddingToLibrary": "Couldn't add item to the library",
156
     "errorAddingToLibrary": "Couldn't add item to the library",
157
     "errorRemovingFromLibrary": "Couldn't remove item from the library",
157
     "errorRemovingFromLibrary": "Couldn't remove item from the library",
158
     "confirmAddLibrary": "This will add {{numShapes}} shape(s) to your library. Are you sure?",
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
     "cannotRestoreFromImage": "Scene couldn't be restored from this image file",
160
     "cannotRestoreFromImage": "Scene couldn't be restored from this image file",
161
     "invalidSceneUrl": "Couldn't import scene from the supplied URL. It's either malformed, or doesn't contain valid Excalidraw JSON data.",
161
     "invalidSceneUrl": "Couldn't import scene from the supplied URL. It's either malformed, or doesn't contain valid Excalidraw JSON data.",
162
     "resetLibrary": "This will clear your library. Are you sure?",
162
     "resetLibrary": "This will clear your library. Are you sure?",
163
     "invalidEncryptionKey": "Encryption key must be of 22 characters. Live collaboration is disabled."
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
   "toolBar": {
172
   "toolBar": {
166
     "selection": "Selection",
173
     "selection": "Selection",
174
+    "image": "Insert image",
167
     "rectangle": "Rectangle",
175
     "rectangle": "Rectangle",
168
     "diamond": "Diamond",
176
     "diamond": "Diamond",
169
     "ellipse": "Ellipse",
177
     "ellipse": "Ellipse",
188
     "linearElementMulti": "Click on last point or press Escape or Enter to finish",
196
     "linearElementMulti": "Click on last point or press Escape or Enter to finish",
189
     "lockAngle": "You can constrain angle by holding SHIFT",
197
     "lockAngle": "You can constrain angle by holding SHIFT",
190
     "resize": "You can constrain proportions by holding SHIFT while resizing,\nhold ALT to resize from the center",
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
     "rotate": "You can constrain angles by holding SHIFT while rotating",
200
     "rotate": "You can constrain angles by holding SHIFT while rotating",
192
     "lineEditor_info": "Double-click or press Enter to edit points",
201
     "lineEditor_info": "Double-click or press Enter to edit points",
193
     "lineEditor_pointSelected": "Press Delete to remove point, CtrlOrCmd+D to duplicate, or drag to move",
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
   "canvasError": {
206
   "canvasError": {
197
     "cannotShowPreview": "Cannot show preview",
207
     "cannotShowPreview": "Cannot show preview",

+ 25
- 0
src/packages/excalidraw/CHANGELOG.md 파일 보기

13
 
13
 
14
 ## Unreleased
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
 ## Excalidraw API
40
 ## Excalidraw API
17
 
41
 
18
 ### Features
42
 ### Features
380
 - #### BREAKING CHANGE
404
 - #### BREAKING CHANGE
381
   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.
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
 - Append `location.pathname` to `libraryReturnUrl` default url [#3325](https://github.com/excalidraw/excalidraw/pull/3325).
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
 ### Build
409
 ### Build
385
 
410
 

+ 18
- 2
src/packages/excalidraw/README_NEXT.md 파일 보기

379
 | [`handleKeyboardGlobally`](#handleKeyboardGlobally) | boolean | false | Indicates whether to bind the keyboard events to document. |
379
 | [`handleKeyboardGlobally`](#handleKeyboardGlobally) | boolean | false | Indicates whether to bind the keyboard events to document. |
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. |
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
 | [`autoFocus`](#autoFocus) | boolean | false | Implies whether to focus the Excalidraw component on page load |
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
 ### Dimensions of Excalidraw
384
 ### Dimensions of Excalidraw
384
 
385
 
448
 | --- | --- | --- |
449
 | --- | --- | --- |
449
 | ready | `boolean` | This is set to true once Excalidraw is rendered |
450
 | ready | `boolean` | This is set to true once Excalidraw is rendered |
450
 | 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
 | 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
 | resetScene | `({ resetLoadingState: boolean }) => void` | Resets the scene. If `resetLoadingState` is passed as true then it will also force set the loading state to false. |
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
 | 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 |
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
 | 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 |
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
 ### `updateScene`
473
 ### `updateScene`
472
 
474
 
473
 <pre>
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
 </pre>
477
 </pre>
476
 
478
 
477
 You can use this function to update the scene with the sceneData. It accepts the below attributes.
479
 You can use this function to update the scene with the sceneData. It accepts the below attributes.
483
 | `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. |
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
 | `commitToHistory` | `boolean` | Implies if the `history (undo/redo)` should be recorded. Defaults to `false`. |
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
 #### `onCollabButtonClick`
494
 #### `onCollabButtonClick`
487
 
495
 
488
 This callback is triggered when clicked on the collab button in excalidraw. If not supplied, the collab dialog button is not rendered.
496
 This callback is triggered when clicked on the collab button in excalidraw. If not supplied, the collab dialog button is not rendered.
662
 
670
 
663
 This prop implies whether to focus the Excalidraw component on page load. Defaults to false.
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
 ### Does it support collaboration ?
681
 ### Does it support collaboration ?
666
 
682
 
667
 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).
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 파일 보기

34
     handleKeyboardGlobally = false,
34
     handleKeyboardGlobally = false,
35
     onLibraryChange,
35
     onLibraryChange,
36
     autoFocus = false,
36
     autoFocus = false,
37
+    generateIdForFile,
37
   } = props;
38
   } = props;
38
 
39
 
39
   const canvasActions = props.UIOptions?.canvasActions;
40
   const canvasActions = props.UIOptions?.canvasActions;
94
         handleKeyboardGlobally={handleKeyboardGlobally}
95
         handleKeyboardGlobally={handleKeyboardGlobally}
95
         onLibraryChange={onLibraryChange}
96
         onLibraryChange={onLibraryChange}
96
         autoFocus={autoFocus}
97
         autoFocus={autoFocus}
98
+        generateIdForFile={generateIdForFile}
97
       />
99
       />
98
     </InitializeApp>
100
     </InitializeApp>
99
   );
101
   );
187
 export { isLinearElement } from "../../element/typeChecks";
189
 export { isLinearElement } from "../../element/typeChecks";
188
 
190
 
189
 export { FONT_FAMILY, THEME } from "../../constants";
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 파일 보기

3
   exportToSvg as _exportToSvg,
3
   exportToSvg as _exportToSvg,
4
 } from "../scene/export";
4
 } from "../scene/export";
5
 import { getDefaultAppState } from "../appState";
5
 import { getDefaultAppState } from "../appState";
6
-import { AppState } from "../types";
6
+import { AppState, BinaryFiles } from "../types";
7
 import { ExcalidrawElement } from "../element/types";
7
 import { ExcalidrawElement } from "../element/types";
8
 import { getNonDeletedElements } from "../element";
8
 import { getNonDeletedElements } from "../element";
9
 import { restore } from "../data/restore";
9
 import { restore } from "../data/restore";
10
+import { MIME_TYPES } from "../constants";
10
 
11
 
11
 type ExportOpts = {
12
 type ExportOpts = {
12
   elements: readonly ExcalidrawElement[];
13
   elements: readonly ExcalidrawElement[];
13
   appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
14
   appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
15
+  files: BinaryFiles | null;
14
   getDimensions?: (
16
   getDimensions?: (
15
     width: number,
17
     width: number,
16
     height: number,
18
     height: number,
20
 export const exportToCanvas = ({
22
 export const exportToCanvas = ({
21
   elements,
23
   elements,
22
   appState,
24
   appState,
25
+  files,
23
   getDimensions = (width, height) => ({ width, height, scale: 1 }),
26
   getDimensions = (width, height) => ({ width, height, scale: 1 }),
24
 }: ExportOpts) => {
27
 }: ExportOpts) => {
25
   const { elements: restoredElements, appState: restoredAppState } = restore(
28
   const { elements: restoredElements, appState: restoredAppState } = restore(
31
   return _exportToCanvas(
34
   return _exportToCanvas(
32
     getNonDeletedElements(restoredElements),
35
     getNonDeletedElements(restoredElements),
33
     { ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 },
36
     { ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 },
37
+    files || {},
34
     { exportBackground, viewBackgroundColor },
38
     { exportBackground, viewBackgroundColor },
35
     (width: number, height: number) => {
39
     (width: number, height: number) => {
36
       const canvas = document.createElement("canvas");
40
       const canvas = document.createElement("canvas");
44
   );
48
   );
45
 };
49
 };
46
 
50
 
47
-export const exportToBlob = (
51
+export const exportToBlob = async (
48
   opts: ExportOpts & {
52
   opts: ExportOpts & {
49
     mimeType?: string;
53
     mimeType?: string;
50
     quality?: number;
54
     quality?: number;
51
   },
55
   },
52
 ): Promise<Blob | null> => {
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
   if (mimeType === "image/jpg") {
66
   if (mimeType === "image/jpg") {
62
-    mimeType = "image/jpeg";
67
+    mimeType = MIME_TYPES.jpg;
63
   }
68
   }
64
 
69
 
65
   quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8;
70
   quality = quality ? quality : /image\/jpe?g/.test(mimeType) ? 0.92 : 0.8;
78
 export const exportToSvg = async ({
83
 export const exportToSvg = async ({
79
   elements,
84
   elements,
80
   appState = getDefaultAppState(),
85
   appState = getDefaultAppState(),
86
+  files = {},
81
   exportPadding,
87
   exportPadding,
82
 }: Omit<ExportOpts, "getDimensions"> & {
88
 }: Omit<ExportOpts, "getDimensions"> & {
83
   exportPadding?: number;
89
   exportPadding?: number;
87
     null,
93
     null,
88
     null,
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
 export { serializeAsJSON } from "../data/json";
106
 export { serializeAsJSON } from "../data/json";

+ 155
- 19
src/renderer/renderElement.ts 파일 보기

5
   Arrowhead,
5
   Arrowhead,
6
   NonDeletedExcalidrawElement,
6
   NonDeletedExcalidrawElement,
7
   ExcalidrawFreeDrawElement,
7
   ExcalidrawFreeDrawElement,
8
+  ExcalidrawImageElement,
8
 } from "../element/types";
9
 } from "../element/types";
9
 import {
10
 import {
10
   isTextElement,
11
   isTextElement,
11
   isLinearElement,
12
   isLinearElement,
12
   isFreeDrawElement,
13
   isFreeDrawElement,
14
+  isInitializedImageElement,
13
 } from "../element/typeChecks";
15
 } from "../element/typeChecks";
14
 import {
16
 import {
15
   getDiamondPoints,
17
   getDiamondPoints,
21
 import { RoughSVG } from "roughjs/bin/svg";
23
 import { RoughSVG } from "roughjs/bin/svg";
22
 import { RoughGenerator } from "roughjs/bin/generator";
24
 import { RoughGenerator } from "roughjs/bin/generator";
23
 import { SceneState } from "../scene/types";
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
 import { isPathALoop } from "../math";
27
 import { isPathALoop } from "../math";
32
 import rough from "roughjs/bin/rough";
28
 import rough from "roughjs/bin/rough";
33
-import { Zoom } from "../types";
29
+import { AppState, BinaryFiles, Zoom } from "../types";
34
 import { getDefaultAppState } from "../appState";
30
 import { getDefaultAppState } from "../appState";
31
+import { MAX_DECIMALS_FOR_SVG_EXPORT, MIME_TYPES, SVG_NS } from "../constants";
35
 import { getStroke, StrokeOptions } from "perfect-freehand";
32
 import { getStroke, StrokeOptions } from "perfect-freehand";
36
-import { MAX_DECIMALS_FOR_SVG_EXPORT } from "../constants";
37
 
33
 
38
 const defaultAppState = getDefaultAppState();
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
 const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
43
 const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
41
 
44
 
42
 const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
45
 const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
47
 export interface ExcalidrawElementWithCanvas {
50
 export interface ExcalidrawElementWithCanvas {
48
   element: ExcalidrawElement | ExcalidrawTextElement;
51
   element: ExcalidrawElement | ExcalidrawTextElement;
49
   canvas: HTMLCanvasElement;
52
   canvas: HTMLCanvasElement;
53
+  theme: SceneState["theme"];
50
   canvasZoom: Zoom["value"];
54
   canvasZoom: Zoom["value"];
51
   canvasOffsetX: number;
55
   canvasOffsetX: number;
52
   canvasOffsetY: number;
56
   canvasOffsetY: number;
55
 const generateElementCanvas = (
59
 const generateElementCanvas = (
56
   element: NonDeletedExcalidrawElement,
60
   element: NonDeletedExcalidrawElement,
57
   zoom: Zoom,
61
   zoom: Zoom,
62
+  sceneState: SceneState,
58
 ): ExcalidrawElementWithCanvas => {
63
 ): ExcalidrawElementWithCanvas => {
59
   const canvas = document.createElement("canvas");
64
   const canvas = document.createElement("canvas");
60
   const context = canvas.getContext("2d")!;
65
   const context = canvas.getContext("2d")!;
111
 
116
 
112
   const rc = rough.canvas(canvas);
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
   context.restore();
133
   context.restore();
134
+
116
   return {
135
   return {
117
     element,
136
     element,
118
     canvas,
137
     canvas,
138
+    theme: sceneState.theme,
119
     canvasZoom: zoom.value,
139
     canvasZoom: zoom.value,
120
     canvasOffsetX,
140
     canvasOffsetX,
121
     canvasOffsetY,
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
 const drawElementOnCanvas = (
181
 const drawElementOnCanvas = (
126
   element: NonDeletedExcalidrawElement,
182
   element: NonDeletedExcalidrawElement,
127
   rc: RoughCanvas,
183
   rc: RoughCanvas,
128
   context: CanvasRenderingContext2D,
184
   context: CanvasRenderingContext2D,
185
+  sceneState: SceneState,
129
 ) => {
186
 ) => {
130
   context.globalAlpha = element.opacity / 100;
187
   context.globalAlpha = element.opacity / 100;
131
   switch (element.type) {
188
   switch (element.type) {
160
       context.restore();
217
       context.restore();
161
       break;
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
     default: {
237
     default: {
164
       if (isTextElement(element)) {
238
       if (isTextElement(element)) {
165
         const rtl = isRTL(element.text);
239
         const rtl = isRTL(element.text);
254
   switch (element.type) {
328
   switch (element.type) {
255
     case "rectangle":
329
     case "rectangle":
256
     case "diamond":
330
     case "diamond":
331
+    case "image":
257
     case "ellipse": {
332
     case "ellipse": {
258
       options.fillStyle = element.fillStyle;
333
       options.fillStyle = element.fillStyle;
259
       options.fill =
334
       options.fill =
459
         shape = [];
534
         shape = [];
460
         break;
535
         break;
461
       }
536
       }
462
-      case "text": {
537
+      case "text":
538
+      case "image": {
463
         // just to ensure we don't regenerate element.canvas on rerenders
539
         // just to ensure we don't regenerate element.canvas on rerenders
464
         shape = [];
540
         shape = [];
465
         break;
541
         break;
471
 
547
 
472
 const generateElementWithCanvas = (
548
 const generateElementWithCanvas = (
473
   element: NonDeletedExcalidrawElement,
549
   element: NonDeletedExcalidrawElement,
474
-  sceneState?: SceneState,
550
+  sceneState: SceneState,
475
 ) => {
551
 ) => {
476
   const zoom: Zoom = sceneState ? sceneState.zoom : defaultAppState.zoom;
552
   const zoom: Zoom = sceneState ? sceneState.zoom : defaultAppState.zoom;
477
   const prevElementWithCanvas = elementWithCanvasCache.get(element);
553
   const prevElementWithCanvas = elementWithCanvasCache.get(element);
479
     prevElementWithCanvas &&
555
     prevElementWithCanvas &&
480
     prevElementWithCanvas.canvasZoom !== zoom.value &&
556
     prevElementWithCanvas.canvasZoom !== zoom.value &&
481
     !sceneState?.shouldCacheIgnoreZoom;
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
     elementWithCanvasCache.set(element, elementWithCanvas);
566
     elementWithCanvasCache.set(element, elementWithCanvas);
486
 
567
 
509
 
590
 
510
   const cx = ((x1 + x2) / 2 + sceneState.scrollX) * window.devicePixelRatio;
591
   const cx = ((x1 + x2) / 2 + sceneState.scrollX) * window.devicePixelRatio;
511
   const cy = ((y1 + y2) / 2 + sceneState.scrollY) * window.devicePixelRatio;
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
   context.save();
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
   context.drawImage(
613
   context.drawImage(
518
     elementWithCanvas.canvas!,
614
     elementWithCanvas.canvas!,
567
         context.translate(cx, cy);
663
         context.translate(cx, cy);
568
         context.rotate(element.angle);
664
         context.rotate(element.angle);
569
         context.translate(-shiftX, -shiftY);
665
         context.translate(-shiftX, -shiftY);
570
-        drawElementOnCanvas(element, rc, context);
666
+        drawElementOnCanvas(element, rc, context, sceneState);
571
         context.restore();
667
         context.restore();
572
       }
668
       }
573
 
669
 
578
     case "ellipse":
674
     case "ellipse":
579
     case "line":
675
     case "line":
580
     case "arrow":
676
     case "arrow":
677
+    case "image":
581
     case "text": {
678
     case "text": {
582
       generateElementShape(element, generator);
679
       generateElementShape(element, generator);
583
       if (renderOptimizations) {
680
       if (renderOptimizations) {
596
         context.translate(cx, cy);
693
         context.translate(cx, cy);
597
         context.rotate(element.angle);
694
         context.rotate(element.angle);
598
         context.translate(-shiftX, -shiftY);
695
         context.translate(-shiftX, -shiftY);
599
-        drawElementOnCanvas(element, rc, context);
696
+        drawElementOnCanvas(element, rc, context, sceneState);
600
         context.restore();
697
         context.restore();
601
       }
698
       }
602
       break;
699
       break;
628
   element: NonDeletedExcalidrawElement,
725
   element: NonDeletedExcalidrawElement,
629
   rsvg: RoughSVG,
726
   rsvg: RoughSVG,
630
   svgRoot: SVGElement,
727
   svgRoot: SVGElement,
728
+  files: BinaryFiles,
631
   offsetX?: number,
729
   offsetX?: number,
632
   offsetY?: number,
730
   offsetY?: number,
633
 ) => {
731
 ) => {
723
       svgRoot.appendChild(node);
821
       svgRoot.appendChild(node);
724
       break;
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
     default: {
862
     default: {
727
       if (isTextElement(element)) {
863
       if (isTextElement(element)) {
728
         const opacity = element.opacity / 100;
864
         const opacity = element.opacity / 100;

+ 9
- 3
src/renderer/renderScene.ts 파일 보기

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

+ 2
- 0
src/scene/comparisons.ts 파일 보기

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

+ 27
- 9
src/scene/export.ts 파일 보기

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

+ 3
- 2
src/scene/types.ts 파일 보기

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

+ 17
- 3
src/shapes.tsx 파일 보기

92
     value: "text",
92
     value: "text",
93
     key: KEYS.T,
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
 ] as const;
108
 ] as const;
96
 
109
 
97
 export const findShapeByKey = (key: string) => {
110
 export const findShapeByKey = (key: string) => {
98
   const shape = SHAPES.find((shape, index) => {
111
   const shape = SHAPES.find((shape, index) => {
99
     return (
112
     return (
100
       key === (index + 1).toString() ||
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
   return shape?.value || null;
120
   return shape?.value || null;

+ 16
- 0
src/tests/__snapshots__/contextmenu.test.tsx.snap 파일 보기

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

+ 52
- 0
src/tests/__snapshots__/regressionTests.test.tsx.snap 파일 보기

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

+ 2
- 2
src/tests/appState.test.tsx 파일 보기

2
 import ExcalidrawApp from "../excalidraw-app";
2
 import ExcalidrawApp from "../excalidraw-app";
3
 import { API } from "./helpers/api";
3
 import { API } from "./helpers/api";
4
 import { getDefaultAppState } from "../appState";
4
 import { getDefaultAppState } from "../appState";
5
-import { EXPORT_DATA_TYPES } from "../constants";
5
+import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
6
 
6
 
7
 const { h } = window;
7
 const { h } = window;
8
 
8
 
36
             elements: [API.createElement({ type: "rectangle", id: "A" })],
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 파일 보기

19
   const loadFromFirebase = async () => null;
19
   const loadFromFirebase = async () => null;
20
   const saveToFirebase = () => {};
20
   const saveToFirebase = () => {};
21
   const isSavedToFirebase = () => true;
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
   return {
31
   return {
24
     loadFromFirebase,
32
     loadFromFirebase,
25
     saveToFirebase,
33
     saveToFirebase,
26
     isSavedToFirebase,
34
     isSavedToFirebase,
35
+    loadFilesFromFirebase,
36
+    saveFilesToFirebase,
27
   };
37
   };
28
 });
38
 });
29
 
39
 

+ 2
- 2
src/tests/export.test.tsx 파일 보기

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

+ 1
- 0
src/tests/fixtures/diagramFixture.ts 파일 보기

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

+ 2
- 2
src/tests/history.test.tsx 파일 보기

5
 import { getDefaultAppState } from "../appState";
5
 import { getDefaultAppState } from "../appState";
6
 import { waitFor } from "@testing-library/react";
6
 import { waitFor } from "@testing-library/react";
7
 import { createUndoAction, createRedoAction } from "../actions/actionHistory";
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
 const { h } = window;
10
 const { h } = window;
11
 
11
 
86
             elements: [API.createElement({ type: "rectangle", id: "B" })],
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 파일 보기

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

+ 10
- 8
src/tests/packages/utils.test.ts 파일 보기

1
 import * as utils from "../../packages/utils";
1
 import * as utils from "../../packages/utils";
2
 import { diagramFactory } from "../fixtures/diagramFixture";
2
 import { diagramFactory } from "../fixtures/diagramFixture";
3
 import * as mockedSceneExportUtils from "../../scene/export";
3
 import * as mockedSceneExportUtils from "../../scene/export";
4
+import { MIME_TYPES } from "../../constants";
4
 
5
 
5
 jest.mock("../../scene/export", () => ({
6
 jest.mock("../../scene/export", () => ({
6
   __esmodule: true,
7
   __esmodule: true,
11
 describe("exportToCanvas", () => {
12
 describe("exportToCanvas", () => {
12
   const EXPORT_PADDING = 10;
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
       ...diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
17
       ...diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
17
     });
18
     });
18
 
19
 
20
     expect(canvas.height).toBe(100 + 2 * EXPORT_PADDING);
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
       ...diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
26
       ...diagramFactory({ elementOverrides: { width: 100, height: 100 } }),
26
       getDimensions: () => ({ width: 200, height: 200, scale: 1 }),
27
       getDimensions: () => ({ width: 200, height: 200, scale: 1 }),
27
     });
28
     });
39
       const blob = await utils.exportToBlob({
40
       const blob = await utils.exportToBlob({
40
         ...diagramFactory(),
41
         ...diagramFactory(),
41
         getDimensions: (width, height) => ({ width, height, scale: 1 }),
42
         getDimensions: (width, height) => ({ width, height, scale: 1 }),
43
+        // testing typo in MIME type (jpg → jpeg)
42
         mimeType: "image/jpg",
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
     it("should default to image/png", async () => {
49
     it("should default to image/png", async () => {
48
       const blob = await utils.exportToBlob({
50
       const blob = await utils.exportToBlob({
49
         ...diagramFactory(),
51
         ...diagramFactory(),
50
       });
52
       });
51
-      expect(blob?.type).toBe("image/png");
53
+      expect(blob?.type).toBe(MIME_TYPES.png);
52
     });
54
     });
53
 
55
 
54
     it("should warn when using quality with image/png", async () => {
56
     it("should warn when using quality with image/png", async () => {
58
 
60
 
59
       await utils.exportToBlob({
61
       await utils.exportToBlob({
60
         ...diagramFactory(),
62
         ...diagramFactory(),
61
-        mimeType: "image/png",
63
+        mimeType: MIME_TYPES.png,
62
         quality: 1,
64
         quality: 1,
63
       });
65
       });
64
 
66
 
65
       expect(consoleSpy).toHaveBeenCalledWith(
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 파일 보기

74
 exports[`exportToSvg with exportEmbedScene 1`] = `
74
 exports[`exportToSvg with exportEmbedScene 1`] = `
75
 "
75
 "
76
   <!-- svg-source:excalidraw -->
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
   <defs>
78
   <defs>
79
     <style>
79
     <style>
80
       @font-face {
80
       @font-face {

+ 48
- 23
src/tests/scene/export.test.ts 파일 보기

13
   const DEFAULT_OPTIONS = {
13
   const DEFAULT_OPTIONS = {
14
     exportBackground: false,
14
     exportBackground: false,
15
     viewBackgroundColor: "#ffffff",
15
     viewBackgroundColor: "#ffffff",
16
+    files: {},
16
   };
17
   };
17
 
18
 
18
   it("with default arguments", async () => {
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
     expect(svgElement).toMatchSnapshot();
26
     expect(svgElement).toMatchSnapshot();
22
   });
27
   });
24
   it("with background color", async () => {
29
   it("with background color", async () => {
25
     const BACKGROUND_COLOR = "#abcdef";
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
     expect(svgElement.querySelector("rect")).toHaveAttribute(
42
     expect(svgElement.querySelector("rect")).toHaveAttribute(
34
       "fill",
43
       "fill",
37
   });
46
   });
38
 
47
 
39
   it("with dark mode", async () => {
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
     expect(svgElement.getAttribute("filter")).toMatchInlineSnapshot(
58
     expect(svgElement.getAttribute("filter")).toMatchInlineSnapshot(
46
       `"themeFilter"`,
59
       `"themeFilter"`,
48
   });
61
   });
49
 
62
 
50
   it("with exportPadding", async () => {
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
     expect(svgElement).toHaveAttribute("height", ELEMENT_HEIGHT.toString());
73
     expect(svgElement).toHaveAttribute("height", ELEMENT_HEIGHT.toString());
57
     expect(svgElement).toHaveAttribute("width", ELEMENT_WIDTH.toString());
74
     expect(svgElement).toHaveAttribute("width", ELEMENT_WIDTH.toString());
64
   it("with scale", async () => {
81
   it("with scale", async () => {
65
     const SCALE = 2;
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
     expect(svgElement).toHaveAttribute(
94
     expect(svgElement).toHaveAttribute(
74
       "height",
95
       "height",
81
   });
102
   });
82
 
103
 
83
   it("with exportEmbedScene", async () => {
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
     expect(svgElement.innerHTML).toMatchSnapshot();
113
     expect(svgElement.innerHTML).toMatchSnapshot();
89
   });
114
   });
90
 });
115
 });

+ 2
- 0
src/tests/test-utils.ts 파일 보기

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

+ 46
- 1
src/types.ts 파일 보기

10
   Arrowhead,
10
   Arrowhead,
11
   ChartType,
11
   ChartType,
12
   FontFamilyValues,
12
   FontFamilyValues,
13
+  FileId,
14
+  ExcalidrawImageElement,
13
   Theme,
15
   Theme,
14
 } from "./element/types";
16
 } from "./element/types";
15
 import { SHAPES } from "./shapes";
17
 import { SHAPES } from "./shapes";
24
 import { ClipboardData } from "./clipboard";
26
 import { ClipboardData } from "./clipboard";
25
 import { isOverScrollBars } from "./scene";
27
 import { isOverScrollBars } from "./scene";
26
 import { MaybeTransformHandleType } from "./element/transformHandles";
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
 export type Point = Readonly<RoughPoint>;
33
 export type Point = Readonly<RoughPoint>;
30
 
34
 
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
 export type AppState = {
66
 export type AppState = {
47
   isLoading: boolean;
67
   isLoading: boolean;
48
   errorMessage: string | null;
68
   errorMessage: string | null;
127
         shown: true;
147
         shown: true;
128
         data: Spreadsheet;
148
         data: Spreadsheet;
129
       };
149
       };
150
+  /** imageElement waiting to be placed on canvas */
151
+  pendingImageElement: NonDeleted<ExcalidrawImageElement> | null;
130
 };
152
 };
131
 
153
 
132
 export type NormalizedZoomValue = number & { _brand: "normalizedZoom" };
154
 export type NormalizedZoomValue = number & { _brand: "normalizedZoom" };
172
   onChange?: (
194
   onChange?: (
173
     elements: readonly ExcalidrawElement[],
195
     elements: readonly ExcalidrawElement[],
174
     appState: AppState,
196
     appState: AppState,
197
+    files: BinaryFiles,
175
   ) => void;
198
   ) => void;
176
   initialData?: ImportedDataState | null | Promise<ImportedDataState | null>;
199
   initialData?: ImportedDataState | null | Promise<ImportedDataState | null>;
177
   excalidrawRef?: ForwardRef<ExcalidrawAPIRefValue>;
200
   excalidrawRef?: ForwardRef<ExcalidrawAPIRefValue>;
207
   handleKeyboardGlobally?: boolean;
230
   handleKeyboardGlobally?: boolean;
208
   onLibraryChange?: (libraryItems: LibraryItems) => void | Promise<any>;
231
   onLibraryChange?: (libraryItems: LibraryItems) => void | Promise<any>;
209
   autoFocus?: boolean;
232
   autoFocus?: boolean;
233
+  generateIdForFile?: (file: File) => string | Promise<string>;
210
 }
234
 }
211
 
235
 
212
 export type SceneData = {
236
 export type SceneData = {
227
   onExportToBackend?: (
251
   onExportToBackend?: (
228
     exportedElements: readonly NonDeletedExcalidrawElement[],
252
     exportedElements: readonly NonDeletedExcalidrawElement[],
229
     appState: AppState,
253
     appState: AppState,
254
+    files: BinaryFiles,
230
     canvas: HTMLCanvasElement | null,
255
     canvas: HTMLCanvasElement | null,
231
   ) => void;
256
   ) => void;
232
   renderCustomUI?: (
257
   renderCustomUI?: (
233
     exportedElements: readonly NonDeletedExcalidrawElement[],
258
     exportedElements: readonly NonDeletedExcalidrawElement[],
234
     appState: AppState,
259
     appState: AppState,
260
+    files: BinaryFiles,
235
     canvas: HTMLCanvasElement | null,
261
     canvas: HTMLCanvasElement | null,
236
   ) => JSX.Element;
262
   ) => JSX.Element;
237
 };
263
 };
258
   handleKeyboardGlobally: boolean;
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
 export type PointerDownState = Readonly<{
304
 export type PointerDownState = Readonly<{
262
   // The first position at which pointerDown happened
305
   // The first position at which pointerDown happened
263
   origin: Readonly<{ x: number; y: number }>;
306
   origin: Readonly<{ x: number; y: number }>;
327
   scrollToContent: InstanceType<typeof App>["scrollToContent"];
370
   scrollToContent: InstanceType<typeof App>["scrollToContent"];
328
   getSceneElements: InstanceType<typeof App>["getSceneElements"];
371
   getSceneElements: InstanceType<typeof App>["getSceneElements"];
329
   getAppState: () => InstanceType<typeof App>["state"];
372
   getAppState: () => InstanceType<typeof App>["state"];
373
+  getFiles: () => InstanceType<typeof App>["files"];
330
   refresh: InstanceType<typeof App>["refresh"];
374
   refresh: InstanceType<typeof App>["refresh"];
331
   importLibrary: InstanceType<typeof App>["importLibraryFromUrl"];
375
   importLibrary: InstanceType<typeof App>["importLibraryFromUrl"];
332
   setToastMessage: InstanceType<typeof App>["setToastMessage"];
376
   setToastMessage: InstanceType<typeof App>["setToastMessage"];
377
+  addFiles: (data: BinaryFileData[]) => void;
333
   readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
378
   readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
334
   ready: true;
379
   ready: true;
335
   id: string;
380
   id: string;

+ 9
- 3
src/utils.ts 파일 보기

10
 import { unstable_batchedUpdates } from "react-dom";
10
 import { unstable_batchedUpdates } from "react-dom";
11
 import { isDarwin } from "./keys";
11
 import { isDarwin } from "./keys";
12
 
12
 
13
-export const SVG_NS = "http://www.w3.org/2000/svg";
14
-
15
 let mockDateTime: string | null = null;
13
 let mockDateTime: string | null = null;
16
 
14
 
17
 export const setDateTimeForTests = (dateTime: string) => {
15
 export const setDateTimeForTests = (dateTime: string) => {
192
   }
190
   }
193
   if (shape === "selection") {
191
   if (shape === "selection") {
194
     resetCursor(canvas);
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
     canvas.style.cursor = CURSOR_TYPE.CROSSHAIR;
196
     canvas.style.cursor = CURSOR_TYPE.CROSSHAIR;
197
   }
197
   }
198
 };
198
 };
443
     parent = parent.parentElement;
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 파일 보기

2075
   version "4.0.0"
2075
   version "4.0.0"
2076
   resolved "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz"
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
 "@types/prettier@^2.0.0":
2083
 "@types/prettier@^2.0.0":
2079
   version "2.2.3"
2084
   version "2.2.3"
2080
   resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.2.3.tgz#ef65165aea2924c9359205bf748865b8881753c0"
2085
   resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.2.3.tgz#ef65165aea2924c9359205bf748865b8881753c0"
3035
   version "1.0.0"
3040
   version "1.0.0"
3036
   resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz"
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
 base64-arraybuffer@0.1.4:
3048
 base64-arraybuffer@0.1.4:
3039
   version "0.1.4"
3049
   version "0.1.4"
3040
   resolved "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz"
3050
   resolved "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz"
4023
   version "3.6.5"
4033
   version "3.6.5"
4024
   resolved "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz"
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
   version "2.6.12"
4037
   version "2.6.12"
4028
   resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec"
4038
   resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec"
4029
 
4039
 
4667
   version "2.1.0"
4677
   version "2.1.0"
4668
   resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.1.0.tgz#a851c080a6d1c3d94344aed151d99f669edf585e"
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
 domexception@^2.0.1:
4687
 domexception@^2.0.1:
4671
   version "2.0.1"
4688
   version "2.0.1"
4672
   resolved "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz"
4689
   resolved "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz"
5480
   version "1.3.0"
5497
   version "1.3.0"
5481
   resolved "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz"
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
 fast-deep-equal@^3.1.1:
5508
 fast-deep-equal@^3.1.1:
5484
   version "3.1.3"
5509
   version "3.1.3"
5485
   resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
5510
   resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
6127
     pify "^2.0.0"
6152
     pify "^2.0.0"
6128
     pinkie-promise "^2.0.0"
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
 google-auth-library@^6.1.1, google-auth-library@^6.1.2, google-auth-library@^6.1.3:
6160
 google-auth-library@^6.1.1, google-auth-library@^6.1.2, google-auth-library@^6.1.3:
6131
   version "6.1.6"
6161
   version "6.1.6"
6132
   resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-6.1.6.tgz#deacdcdb883d9ed6bac78bb5d79a078877fdf572"
6162
   resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-6.1.6.tgz#deacdcdb883d9ed6bac78bb5d79a078877fdf572"
6545
   dependencies:
6575
   dependencies:
6546
     postcss "^7.0.14"
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
 idb@3.0.2:
6585
 idb@3.0.2:
6549
   version "3.0.2"
6586
   version "3.0.2"
6550
   resolved "https://registry.npmjs.org/idb/-/idb-3.0.2.tgz"
6587
   resolved "https://registry.npmjs.org/idb/-/idb-3.0.2.tgz"
6571
   version "5.1.8"
6608
   version "5.1.8"
6572
   resolved "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz"
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
 immediate@~3.0.5:
6618
 immediate@~3.0.5:
6575
   version "3.0.6"
6619
   version "3.0.6"
6576
   resolved "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz"
6620
   resolved "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz"
8112
   dependencies:
8156
   dependencies:
8113
     lodash.keys "~2.4.1"
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
   version "4.17.21"
8160
   version "4.17.21"
8117
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
8161
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
8118
 
8162
 
8536
     dns-packet "^1.3.1"
8580
     dns-packet "^1.3.1"
8537
     thunky "^1.0.2"
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
 mute-stream@0.0.7:
8591
 mute-stream@0.0.7:
8540
   version "0.0.7"
8592
   version "0.0.7"
8541
   resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz"
8593
   resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz"
9274
   version "2.1.0"
9326
   version "2.1.0"
9275
   resolved "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz"
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
 picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1, picomatch@^2.2.2:
9340
 picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1, picomatch@^2.2.2:
9278
   version "2.2.2"
9341
   version "2.2.2"
9279
   resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz"
9342
   resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz"
10502
   dependencies:
10565
   dependencies:
10503
     picomatch "^2.2.1"
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
 recursive-readdir@2.2.2:
10578
 recursive-readdir@2.2.2:
10506
   version "2.2.2"
10579
   version "2.2.2"
10507
   resolved "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.2.tgz"
10580
   resolved "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.2.tgz"
10916
   dependencies:
10989
   dependencies:
10917
     tslib "^1.9.0"
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
 safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
10997
 safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
10920
   version "5.1.2"
10998
   version "5.1.2"
10921
   resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz"
10999
   resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz"
11122
     is-plain-object "^2.0.3"
11200
     is-plain-object "^2.0.3"
11123
     split-string "^3.0.1"
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
   version "1.0.5"
11204
   version "1.0.5"
11127
   resolved "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz"
11205
   resolved "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz"
11128
 
11206
 
12062
   dependencies:
12140
   dependencies:
12063
     punycode "^2.1.1"
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
 "traverse@>=0.3.0 <0.4":
12150
 "traverse@>=0.3.0 <0.4":
12066
   version "0.3.9"
12151
   version "0.3.9"
12067
   resolved "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz"
12152
   resolved "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz"
12196
   resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.4.tgz#8610b59747de028fda898a8aef0e103f156d0961"
12281
   resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.4.tgz#8610b59747de028fda898a8aef0e103f156d0961"
12197
   integrity sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==
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
 unbox-primitive@^1.0.0:
12298
 unbox-primitive@^1.0.0:
12200
   version "1.0.1"
12299
   version "1.0.1"
12201
   resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471"
12300
   resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471"
12517
   dependencies:
12616
   dependencies:
12518
     defaults "^1.0.3"
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
 webidl-conversions@^5.0.0:
12624
 webidl-conversions@^5.0.0:
12521
   version "5.0.0"
12625
   version "5.0.0"
12522
   resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz"
12626
   resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz"
12636
   version "0.1.4"
12740
   version "0.1.4"
12637
   resolved "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz"
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
 whatwg-encoding@^1.0.5:
12748
 whatwg-encoding@^1.0.5:
12640
   version "1.0.5"
12749
   version "1.0.5"
12641
   resolved "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz"
12750
   resolved "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz"
12662
     tr46 "^2.0.2"
12771
     tr46 "^2.0.2"
12663
     webidl-conversions "^6.1.0"
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
 which-boxed-primitive@^1.0.2:
12783
 which-boxed-primitive@^1.0.2:
12666
   version "1.0.2"
12784
   version "1.0.2"
12667
   resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"
12785
   resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"

Loading…
취소
저장