Expose update scene via refs (#2217)

Co-authored-by: dwelle <luzar.david@gmail.com>
pull/2225/head
Aakansha Doshi 4 years ago committed by GitHub
parent 8a10f2a0b8
commit 63566ecb92
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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.

@ -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
src/global.d.ts vendored

@ -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];

@ -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>;
}

Loading…
Cancel
Save