Expose update scene via refs (#2217)
Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
parent
8a10f2a0b8
commit
63566ecb92
@ -32,6 +32,7 @@ import {
|
|||||||
dragNewElement,
|
dragNewElement,
|
||||||
hitTest,
|
hitTest,
|
||||||
isHittingElementBoundingBoxWithoutHittingElement,
|
isHittingElementBoundingBoxWithoutHittingElement,
|
||||||
|
getNonDeletedElements,
|
||||||
} from "../element";
|
} from "../element";
|
||||||
import {
|
import {
|
||||||
getElementsWithinSelection,
|
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> {
|
class App extends React.Component<ExcalidrawProps, AppState> {
|
||||||
canvas: HTMLCanvasElement | null = null;
|
canvas: HTMLCanvasElement | null = null;
|
||||||
rc: RoughCanvas | null = null;
|
rc: RoughCanvas | null = null;
|
||||||
@ -277,6 +284,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
unmounted: boolean = false;
|
unmounted: boolean = false;
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
private excalidrawRef: any;
|
private excalidrawRef: any;
|
||||||
|
private socketInitializationTimer: any;
|
||||||
|
|
||||||
public static defaultProps: Partial<ExcalidrawProps> = {
|
public static defaultProps: Partial<ExcalidrawProps> = {
|
||||||
width: window.innerWidth,
|
width: window.innerWidth,
|
||||||
@ -288,7 +296,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
super(props);
|
super(props);
|
||||||
const defaultAppState = getDefaultAppState();
|
const defaultAppState = getDefaultAppState();
|
||||||
|
|
||||||
const { width, height, user } = props;
|
const { width, height, user, forwardedRef } = props;
|
||||||
this.state = {
|
this.state = {
|
||||||
...defaultAppState,
|
...defaultAppState,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
@ -297,7 +305,11 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
username: user?.name || "",
|
username: user?.name || "",
|
||||||
...this.getCanvasOffsets(),
|
...this.getCanvasOffsets(),
|
||||||
};
|
};
|
||||||
|
if (forwardedRef && "current" in forwardedRef) {
|
||||||
|
forwardedRef.current = {
|
||||||
|
updateScene: this.updateScene,
|
||||||
|
};
|
||||||
|
}
|
||||||
this.scene = new Scene();
|
this.scene = new Scene();
|
||||||
this.excalidrawRef = React.createRef();
|
this.excalidrawRef = React.createRef();
|
||||||
this.actionManager = new ActionManager(
|
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 = () => {
|
private destroySocketClient = () => {
|
||||||
this.setState({
|
this.setState({
|
||||||
isCollaborating: false,
|
isCollaborating: false,
|
||||||
@ -1230,6 +1274,100 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
this.portal.close();
|
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: {
|
private initializeSocketClient = async (opts: {
|
||||||
showLoadingState: boolean;
|
showLoadingState: boolean;
|
||||||
clearScene?: boolean;
|
clearScene?: boolean;
|
||||||
@ -1245,130 +1383,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
const roomID = roomMatch[1];
|
const roomID = roomMatch[1];
|
||||||
const roomKey = roomMatch[2];
|
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
|
// fallback in case you're not alone in the room but still don't receive
|
||||||
// initial SCENE_UPDATE message
|
// initial SCENE_UPDATE message
|
||||||
const initializationTimer = setTimeout(
|
this.socketInitializationTimer = setTimeout(
|
||||||
initialize,
|
this.initializeSocket,
|
||||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
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(
|
const { default: socketIOClient }: any = await import(
|
||||||
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
|
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
|
||||||
);
|
);
|
||||||
@ -1393,12 +1414,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
return;
|
return;
|
||||||
case SCENE.INIT: {
|
case SCENE.INIT: {
|
||||||
if (!this.portal.socketInitialized) {
|
if (!this.portal.socketInitialized) {
|
||||||
updateScene(decryptedData, { init: true });
|
const remoteElements = decryptedData.payload.elements;
|
||||||
|
this.handleRemoteSceneUpdate(remoteElements, { init: true });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case SCENE.UPDATE:
|
case SCENE.UPDATE:
|
||||||
updateScene(decryptedData);
|
this.handleRemoteSceneUpdate(decryptedData.payload.elements);
|
||||||
break;
|
break;
|
||||||
case "MOUSE_LOCATION": {
|
case "MOUSE_LOCATION": {
|
||||||
const {
|
const {
|
||||||
@ -1436,7 +1458,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
if (this.portal.socket) {
|
if (this.portal.socket) {
|
||||||
this.portal.socket.off("first-in-room");
|
this.portal.socket.off("first-in-room");
|
||||||
}
|
}
|
||||||
initialize();
|
this.initializeSocket();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
@ -1447,10 +1469,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||||||
try {
|
try {
|
||||||
const elements = await loadFromFirebase(roomID, roomKey);
|
const elements = await loadFromFirebase(roomID, roomKey);
|
||||||
if (elements) {
|
if (elements) {
|
||||||
updateScene(
|
this.handleRemoteSceneUpdate(elements, { initFromSnapshot: true });
|
||||||
{ type: "SCENE_UPDATE", payload: { elements } },
|
|
||||||
{ initFromSnapshot: true },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// log the error and move on. other peers will sync us the scene.
|
// 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 { InitializeApp } from "../components/InitializeApp";
|
||||||
import App from "../components/App";
|
import App, { ExcalidrawImperativeAPI } from "../components/App";
|
||||||
|
|
||||||
import "../css/app.scss";
|
import "../css/app.scss";
|
||||||
import "../css/styles.scss";
|
import "../css/styles.scss";
|
||||||
@ -17,6 +17,7 @@ const Excalidraw = (props: ExcalidrawProps) => {
|
|||||||
initialData,
|
initialData,
|
||||||
user,
|
user,
|
||||||
onUsernameChange,
|
onUsernameChange,
|
||||||
|
forwardedRef,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -47,13 +48,19 @@ const Excalidraw = (props: ExcalidrawProps) => {
|
|||||||
initialData={initialData}
|
initialData={initialData}
|
||||||
user={user}
|
user={user}
|
||||||
onUsernameChange={onUsernameChange}
|
onUsernameChange={onUsernameChange}
|
||||||
|
forwardedRef={forwardedRef}
|
||||||
/>
|
/>
|
||||||
</IsMobileProvider>
|
</IsMobileProvider>
|
||||||
</InitializeApp>
|
</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: prevInitialData, user: prevUser, ...prev } = prevProps;
|
||||||
const { initialData: nextInitialData, user: nextUser, ...next } = nextProps;
|
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
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
|
// https://github.com/krzkaczor/ts-essentials
|
||||||
type MarkOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
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 { LinearElementEditor } from "./element/linearElementEditor";
|
||||||
import { SuggestedBinding } from "./element/binding";
|
import { SuggestedBinding } from "./element/binding";
|
||||||
import { ImportedDataState } from "./data/types";
|
import { ImportedDataState } from "./data/types";
|
||||||
|
import { ExcalidrawImperativeAPI } from "./components/App";
|
||||||
|
|
||||||
export type FlooredNumber = number & { _brand: "FlooredNumber" };
|
export type FlooredNumber = number & { _brand: "FlooredNumber" };
|
||||||
export type Point = Readonly<RoughPoint>;
|
export type Point = Readonly<RoughPoint>;
|
||||||
@ -132,4 +133,5 @@ export interface ExcalidrawProps {
|
|||||||
name?: string | null;
|
name?: string | null;
|
||||||
};
|
};
|
||||||
onUsernameChange?: (username: string) => void;
|
onUsernameChange?: (username: string) => void;
|
||||||
|
forwardedRef: ForwardRef<ExcalidrawImperativeAPI>;
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user