From 63566ecb929ccfd755f56c7413fcb79ddfaa173b Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Sun, 11 Oct 2020 21:41:26 +0530 Subject: [PATCH] Expose update scene via refs (#2217) Co-authored-by: dwelle --- src/components/App.tsx | 275 ++++++++++++++++++--------------- src/excalidraw-embed/index.tsx | 19 ++- src/global.d.ts | 14 ++ src/types.ts | 2 + 4 files changed, 178 insertions(+), 132 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 1127aafaa..32741ec83 100644 --- a/src/components/App.tsx +++ b/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["updateScene"]; + } + | undefined; + class App extends React.Component { canvas: HTMLCanvasElement | null = null; rc: RoughCanvas | null = null; @@ -277,6 +284,7 @@ class App extends React.Component { unmounted: boolean = false; actionManager: ActionManager; private excalidrawRef: any; + private socketInitializationTimer: any; public static defaultProps: Partial = { width: window.innerWidth, @@ -288,7 +296,7 @@ class App extends React.Component { 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 { 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 { }); }; + 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 { 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) + // 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 { 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) - // 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 { 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 { 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 { 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. diff --git a/src/excalidraw-embed/index.tsx b/src/excalidraw-embed/index.tsx index ffe826d4c..b02bfba3a 100644 --- a/src/excalidraw-embed/index.tsx +++ b/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} /> ); }; -const areEqual = (prevProps: ExcalidrawProps, nextProps: ExcalidrawProps) => { +type PublicExcalidrawProps = Omit; + +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) => ); +export default React.memo(forwardedRefComp, areEqual); diff --git a/src/global.d.ts b/src/global.d.ts index eeae7f3a0..091cf13b3 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -40,3 +40,17 @@ type ResolutionType any> = T extends ( // https://github.com/krzkaczor/ts-essentials type MarkOptional = Omit & Partial>; + +// type getter for interface's callable type +// src: https://stackoverflow.com/a/58658851/927631 +// ----------------------------------------------------------------------------- +type SignatureType = T extends (...args: infer R) => any ? R : never; +type CallableType any> = ( + ...args: SignatureType +) => ReturnType; +// --------------------------------------------------------------------------— + +// Type for React.forwardRef --- supply only the first generic argument T +type ForwardRef = Parameters< + CallableType> +>[1]; diff --git a/src/types.ts b/src/types.ts index 0450e33e0..1fff5f703 100644 --- a/src/types.ts +++ b/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; @@ -132,4 +133,5 @@ export interface ExcalidrawProps { name?: string | null; }; onUsernameChange?: (username: string) => void; + forwardedRef: ForwardRef; }