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