Просмотр исходного кода

Expose update scene via refs (#2217)

Co-authored-by: dwelle <luzar.david@gmail.com>
Aakansha Doshi 4 лет назад
Родитель
Сommit
63566ecb92
4 измененных файлов с 178 добавлено и 132 удалено
  1. 147 128
      src/components/App.tsx
  2. 15 4
      src/excalidraw-embed/index.tsx
  3. 14 0
      src/global.d.ts
  4. 2 0
      src/types.ts

+ 147 - 128
src/components/App.tsx

@@ -32,6 +32,7 @@ import {
   dragNewElement,
   hitTest,
   isHittingElementBoundingBoxWithoutHittingElement,
+  getNonDeletedElements,
 } from "../element";
 import {
   getElementsWithinSelection,
@@ -268,6 +269,12 @@ export type PointerDownState = Readonly<{
   };
 }>;
 
+export type ExcalidrawImperativeAPI =
+  | {
+      updateScene: InstanceType<typeof App>["updateScene"];
+    }
+  | undefined;
+
 class App extends React.Component<ExcalidrawProps, AppState> {
   canvas: HTMLCanvasElement | null = null;
   rc: RoughCanvas | null = null;
@@ -277,6 +284,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
   unmounted: boolean = false;
   actionManager: ActionManager;
   private excalidrawRef: any;
+  private socketInitializationTimer: any;
 
   public static defaultProps: Partial<ExcalidrawProps> = {
     width: window.innerWidth,
@@ -288,7 +296,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     super(props);
     const defaultAppState = getDefaultAppState();
 
-    const { width, height, user } = props;
+    const { width, height, user, forwardedRef } = props;
     this.state = {
       ...defaultAppState,
       isLoading: true,
@@ -297,7 +305,11 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       username: user?.name || "",
       ...this.getCanvasOffsets(),
     };
-
+    if (forwardedRef && "current" in forwardedRef) {
+      forwardedRef.current = {
+        updateScene: this.updateScene,
+      };
+    }
     this.scene = new Scene();
     this.excalidrawRef = React.createRef();
     this.actionManager = new ActionManager(
@@ -1222,6 +1234,38 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     });
   };
 
+  setScrollToCenter = (remoteElements: readonly ExcalidrawElement[]) => {
+    this.setState({
+      ...calculateScrollCenter(
+        getNonDeletedElements(remoteElements),
+        this.state,
+        this.canvas,
+      ),
+    });
+  };
+
+  private handleRemoteSceneUpdate = (
+    elements: readonly ExcalidrawElement[],
+    {
+      init = false,
+      initFromSnapshot = false,
+    }: { init?: boolean; initFromSnapshot?: boolean } = {},
+  ) => {
+    if (init) {
+      history.resumeRecording();
+    }
+
+    if (init || initFromSnapshot) {
+      this.setScrollToCenter(elements);
+    }
+
+    this.updateScene({ elements });
+
+    if (!this.portal.socketInitialized && !initFromSnapshot) {
+      this.initializeSocket();
+    }
+  };
+
   private destroySocketClient = () => {
     this.setState({
       isCollaborating: false,
@@ -1230,6 +1274,100 @@ class App extends React.Component<ExcalidrawProps, AppState> {
     this.portal.close();
   };
 
+  public updateScene = (
+    sceneData: {
+      elements: readonly ExcalidrawElement[];
+      appState?: AppState;
+    },
+    { replaceAll = false }: { replaceAll?: boolean } = {},
+  ) => {
+    // currently we only support syncing background color
+    if (sceneData.appState?.viewBackgroundColor) {
+      this.setState({
+        viewBackgroundColor: sceneData.appState.viewBackgroundColor,
+      });
+    }
+    // Perform reconciliation - in collaboration, if we encounter
+    // elements with more staler versions than ours, ignore them
+    // and keep ours.
+    const currentElements = this.scene.getElementsIncludingDeleted();
+    if (replaceAll || !currentElements.length) {
+      this.scene.replaceAllElements(sceneData.elements);
+    } else {
+      // create a map of ids so we don't have to iterate
+      // over the array more than once.
+      const localElementMap = getElementMap(currentElements);
+
+      // Reconcile
+      const newElements = sceneData.elements
+        .reduce((elements, element) => {
+          // if the remote element references one that's currently
+          //  edited on local, skip it (it'll be added in the next
+          //  step)
+          if (
+            element.id === this.state.editingElement?.id ||
+            element.id === this.state.resizingElement?.id ||
+            element.id === this.state.draggingElement?.id
+          ) {
+            return elements;
+          }
+
+          if (
+            localElementMap.hasOwnProperty(element.id) &&
+            localElementMap[element.id].version > element.version
+          ) {
+            elements.push(localElementMap[element.id]);
+            delete localElementMap[element.id];
+          } else if (
+            localElementMap.hasOwnProperty(element.id) &&
+            localElementMap[element.id].version === element.version &&
+            localElementMap[element.id].versionNonce !== element.versionNonce
+          ) {
+            // resolve conflicting edits deterministically by taking the one with the lowest versionNonce
+            if (
+              localElementMap[element.id].versionNonce < element.versionNonce
+            ) {
+              elements.push(localElementMap[element.id]);
+            } else {
+              // it should be highly unlikely that the two versionNonces are the same. if we are
+              // really worried about this, we can replace the versionNonce with the socket id.
+              elements.push(element);
+            }
+            delete localElementMap[element.id];
+          } else {
+            elements.push(element);
+            delete localElementMap[element.id];
+          }
+
+          return elements;
+        }, [] as Mutable<typeof sceneData.elements>)
+        // add local elements that weren't deleted or on remote
+        .concat(...Object.values(localElementMap));
+
+      // Avoid broadcasting to the rest of the collaborators the scene
+      // we just received!
+      // Note: this needs to be set before replaceAllElements as it
+      // syncronously calls render.
+      this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(newElements);
+
+      this.scene.replaceAllElements(newElements);
+    }
+
+    // We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
+    // when we receive any messages from another peer. This UX can be pretty rough -- if you
+    // undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
+    // right now we think this is the right tradeoff.
+    history.clear();
+  };
+
+  private initializeSocket = () => {
+    this.portal.socketInitialized = true;
+    clearTimeout(this.socketInitializationTimer);
+    if (this.state.isLoading && !this.unmounted) {
+      this.setState({ isLoading: false });
+    }
+  };
+
   private initializeSocketClient = async (opts: {
     showLoadingState: boolean;
     clearScene?: boolean;
@@ -1245,130 +1383,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       const roomID = roomMatch[1];
       const roomKey = roomMatch[2];
 
-      const initialize = () => {
-        this.portal.socketInitialized = true;
-        clearTimeout(initializationTimer);
-        if (this.state.isLoading && !this.unmounted) {
-          this.setState({ isLoading: false });
-        }
-      };
       // fallback in case you're not alone in the room but still don't receive
       //  initial SCENE_UPDATE message
-      const initializationTimer = setTimeout(
-        initialize,
+      this.socketInitializationTimer = setTimeout(
+        this.initializeSocket,
         INITIAL_SCENE_UPDATE_TIMEOUT,
       );
 
-      const updateScene = (
-        decryptedData: SocketUpdateDataSource[SCENE.INIT | SCENE.UPDATE],
-        {
-          init = false,
-          initFromSnapshot = false,
-        }: { init?: boolean; initFromSnapshot?: boolean } = {},
-      ) => {
-        const { elements: remoteElements } = decryptedData.payload;
-
-        if (init) {
-          history.resumeRecording();
-        }
-
-        if (init || initFromSnapshot) {
-          this.setState({
-            ...this.state,
-            ...calculateScrollCenter(
-              remoteElements.filter((element: { isDeleted: boolean }) => {
-                return !element.isDeleted;
-              }),
-              this.state,
-              this.canvas,
-            ),
-          });
-        }
-
-        // Perform reconciliation - in collaboration, if we encounter
-        // elements with more staler versions than ours, ignore them
-        // and keep ours.
-        if (
-          this.scene.getElementsIncludingDeleted() == null ||
-          this.scene.getElementsIncludingDeleted().length === 0
-        ) {
-          this.scene.replaceAllElements(remoteElements);
-        } else {
-          // create a map of ids so we don't have to iterate
-          // over the array more than once.
-          const localElementMap = getElementMap(
-            this.scene.getElementsIncludingDeleted(),
-          );
-
-          // Reconcile
-          const newElements = remoteElements
-            .reduce((elements, element) => {
-              // if the remote element references one that's currently
-              //  edited on local, skip it (it'll be added in the next
-              //  step)
-              if (
-                element.id === this.state.editingElement?.id ||
-                element.id === this.state.resizingElement?.id ||
-                element.id === this.state.draggingElement?.id
-              ) {
-                return elements;
-              }
-
-              if (
-                localElementMap.hasOwnProperty(element.id) &&
-                localElementMap[element.id].version > element.version
-              ) {
-                elements.push(localElementMap[element.id]);
-                delete localElementMap[element.id];
-              } else if (
-                localElementMap.hasOwnProperty(element.id) &&
-                localElementMap[element.id].version === element.version &&
-                localElementMap[element.id].versionNonce !==
-                  element.versionNonce
-              ) {
-                // resolve conflicting edits deterministically by taking the one with the lowest versionNonce
-                if (
-                  localElementMap[element.id].versionNonce <
-                  element.versionNonce
-                ) {
-                  elements.push(localElementMap[element.id]);
-                } else {
-                  // it should be highly unlikely that the two versionNonces are the same. if we are
-                  // really worried about this, we can replace the versionNonce with the socket id.
-                  elements.push(element);
-                }
-                delete localElementMap[element.id];
-              } else {
-                elements.push(element);
-                delete localElementMap[element.id];
-              }
-
-              return elements;
-            }, [] as Mutable<typeof remoteElements>)
-            // add local elements that weren't deleted or on remote
-            .concat(...Object.values(localElementMap));
-
-          // Avoid broadcasting to the rest of the collaborators the scene
-          // we just received!
-          // Note: this needs to be set before replaceAllElements as it
-          // syncronously calls render.
-          this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(
-            newElements,
-          );
-
-          this.scene.replaceAllElements(newElements);
-        }
-
-        // We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
-        // when we receive any messages from another peer. This UX can be pretty rough -- if you
-        // undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
-        // right now we think this is the right tradeoff.
-        history.clear();
-        if (!this.portal.socketInitialized && !initFromSnapshot) {
-          initialize();
-        }
-      };
-
       const { default: socketIOClient }: any = await import(
         /* webpackChunkName: "socketIoClient" */ "socket.io-client"
       );
@@ -1393,12 +1414,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
               return;
             case SCENE.INIT: {
               if (!this.portal.socketInitialized) {
-                updateScene(decryptedData, { init: true });
+                const remoteElements = decryptedData.payload.elements;
+                this.handleRemoteSceneUpdate(remoteElements, { init: true });
               }
               break;
             }
             case SCENE.UPDATE:
-              updateScene(decryptedData);
+              this.handleRemoteSceneUpdate(decryptedData.payload.elements);
               break;
             case "MOUSE_LOCATION": {
               const {
@@ -1436,7 +1458,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
         if (this.portal.socket) {
           this.portal.socket.off("first-in-room");
         }
-        initialize();
+        this.initializeSocket();
       });
 
       this.setState({
@@ -1447,10 +1469,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       try {
         const elements = await loadFromFirebase(roomID, roomKey);
         if (elements) {
-          updateScene(
-            { type: "SCENE_UPDATE", payload: { elements } },
-            { initFromSnapshot: true },
-          );
+          this.handleRemoteSceneUpdate(elements, { initFromSnapshot: true });
         }
       } catch (e) {
         // log the error and move on. other peers will sync us the scene.

+ 15 - 4
src/excalidraw-embed/index.tsx

@@ -1,7 +1,7 @@
-import React, { useEffect } from "react";
+import React, { useEffect, forwardRef } from "react";
 
 import { InitializeApp } from "../components/InitializeApp";
-import App from "../components/App";
+import App, { ExcalidrawImperativeAPI } from "../components/App";
 
 import "../css/app.scss";
 import "../css/styles.scss";
@@ -17,6 +17,7 @@ const Excalidraw = (props: ExcalidrawProps) => {
     initialData,
     user,
     onUsernameChange,
+    forwardedRef,
   } = props;
 
   useEffect(() => {
@@ -47,13 +48,19 @@ const Excalidraw = (props: ExcalidrawProps) => {
           initialData={initialData}
           user={user}
           onUsernameChange={onUsernameChange}
+          forwardedRef={forwardedRef}
         />
       </IsMobileProvider>
     </InitializeApp>
   );
 };
 
-const areEqual = (prevProps: ExcalidrawProps, nextProps: ExcalidrawProps) => {
+type PublicExcalidrawProps = Omit<ExcalidrawProps, "forwardedRef">;
+
+const areEqual = (
+  prevProps: PublicExcalidrawProps,
+  nextProps: PublicExcalidrawProps,
+) => {
   const { initialData: prevInitialData, user: prevUser, ...prev } = prevProps;
   const { initialData: nextInitialData, user: nextUser, ...next } = nextProps;
 
@@ -67,4 +74,8 @@ const areEqual = (prevProps: ExcalidrawProps, nextProps: ExcalidrawProps) => {
   );
 };
 
-export default React.memo(Excalidraw, areEqual);
+const forwardedRefComp = forwardRef<
+  ExcalidrawImperativeAPI,
+  PublicExcalidrawProps
+>((props, ref) => <Excalidraw {...props} forwardedRef={ref} />);
+export default React.memo(forwardedRefComp, areEqual);

+ 14 - 0
src/global.d.ts

@@ -40,3 +40,17 @@ type ResolutionType<T extends (...args: any) => any> = T extends (
 
 // https://github.com/krzkaczor/ts-essentials
 type MarkOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
+
+// type getter for interface's callable type
+// src: https://stackoverflow.com/a/58658851/927631
+// -----------------------------------------------------------------------------
+type SignatureType<T> = T extends (...args: infer R) => any ? R : never;
+type CallableType<T extends (...args: any[]) => any> = (
+  ...args: SignatureType<T>
+) => ReturnType<T>;
+// --------------------------------------------------------------------------—
+
+// Type for React.forwardRef --- supply only the first generic argument T
+type ForwardRef<T, P = any> = Parameters<
+  CallableType<React.ForwardRefRenderFunction<T, P>>
+>[1];

+ 2 - 0
src/types.ts

@@ -15,6 +15,7 @@ import { SocketUpdateDataSource } from "./data";
 import { LinearElementEditor } from "./element/linearElementEditor";
 import { SuggestedBinding } from "./element/binding";
 import { ImportedDataState } from "./data/types";
+import { ExcalidrawImperativeAPI } from "./components/App";
 
 export type FlooredNumber = number & { _brand: "FlooredNumber" };
 export type Point = Readonly<RoughPoint>;
@@ -132,4 +133,5 @@ export interface ExcalidrawProps {
     name?: string | null;
   };
   onUsernameChange?: (username: string) => void;
+  forwardedRef: ForwardRef<ExcalidrawImperativeAPI>;
 }