mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Factor out collaboration code (#2313)
Co-authored-by: Lipis <lipiridis@gmail.com> Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
parent
d8a0dc3b4d
commit
e617ccc252
41 changed files with 2250 additions and 2018 deletions
|
@ -15,8 +15,6 @@ import {
|
|||
getCursorForResizingElement,
|
||||
getPerfectElementSize,
|
||||
getNormalizedDimensions,
|
||||
getSceneVersion,
|
||||
getSyncableElements,
|
||||
newLinearElement,
|
||||
transformElements,
|
||||
getElementWithTransformHandleType,
|
||||
|
@ -42,17 +40,16 @@ import {
|
|||
isSomeElementSelected,
|
||||
calculateScrollCenter,
|
||||
} from "../scene";
|
||||
import {
|
||||
decryptAESGEM,
|
||||
loadScene,
|
||||
loadFromBlob,
|
||||
SOCKET_SERVER,
|
||||
exportCanvas,
|
||||
} from "../data";
|
||||
import Portal from "./Portal";
|
||||
import { loadFromBlob, exportCanvas } from "../data";
|
||||
|
||||
import { renderScene } from "../renderer";
|
||||
import { AppState, GestureEvent, Gesture, ExcalidrawProps } from "../types";
|
||||
import {
|
||||
AppState,
|
||||
GestureEvent,
|
||||
Gesture,
|
||||
ExcalidrawProps,
|
||||
SceneData,
|
||||
} from "../types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
|
@ -75,6 +72,9 @@ import {
|
|||
sceneCoordsToViewportCoords,
|
||||
setCursorForShape,
|
||||
tupleToCoors,
|
||||
ResolvablePromise,
|
||||
resolvablePromise,
|
||||
withBatchedUpdates,
|
||||
} from "../utils";
|
||||
import {
|
||||
KEYS,
|
||||
|
@ -116,28 +116,20 @@ import {
|
|||
DRAGGING_THRESHOLD,
|
||||
TEXT_TO_CENTER_SNAP_THRESHOLD,
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
SCENE,
|
||||
EVENT,
|
||||
ENV,
|
||||
CANVAS_ONLY_ACTIONS,
|
||||
DEFAULT_VERTICAL_ALIGN,
|
||||
GRID_SIZE,
|
||||
LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
|
||||
MIME_TYPES,
|
||||
} from "../constants";
|
||||
import {
|
||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||
TAP_TWICE_TIMEOUT,
|
||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||
TOUCH_CTX_MENU_TIMEOUT,
|
||||
} from "../time_constants";
|
||||
} from "../constants";
|
||||
|
||||
import LayerUI from "./LayerUI";
|
||||
import { ScrollBars, SceneState } from "../scene/types";
|
||||
import { generateCollaborationLink, getCollaborationLinkData } from "../data";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import { invalidateShapeForElement } from "../renderer/renderElement";
|
||||
import { unstable_batchedUpdates } from "react-dom";
|
||||
import {
|
||||
isLinearElement,
|
||||
isLinearElementType,
|
||||
|
@ -146,7 +138,6 @@ import {
|
|||
} from "../element/typeChecks";
|
||||
import { actionFinalize, actionDeleteSelected } from "../actions";
|
||||
|
||||
import throttle from "lodash.throttle";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import {
|
||||
getSelectedGroupIds,
|
||||
|
@ -175,32 +166,15 @@ import {
|
|||
import { MaybeTransformHandleType } from "../element/transformHandles";
|
||||
import { renderSpreadsheet } from "../charts";
|
||||
import { isValidLibrary } from "../data/json";
|
||||
import {
|
||||
loadFromFirebase,
|
||||
saveToFirebase,
|
||||
isSavedToFirebase,
|
||||
} from "../data/firebase";
|
||||
import { getNewZoom } from "../scene/zoom";
|
||||
import { restore } from "../data/restore";
|
||||
import {
|
||||
EVENT_DIALOG,
|
||||
EVENT_LIBRARY,
|
||||
EVENT_SHAPE,
|
||||
EVENT_SHARE,
|
||||
trackEvent,
|
||||
} from "../analytics";
|
||||
|
||||
/**
|
||||
* @param func handler taking at most single parameter (event).
|
||||
*/
|
||||
const withBatchedUpdates = <
|
||||
TFunction extends ((event: any) => void) | (() => void)
|
||||
>(
|
||||
func: Parameters<TFunction>["length"] extends 0 | 1 ? TFunction : never,
|
||||
) =>
|
||||
((event) => {
|
||||
unstable_batchedUpdates(func as TFunction, event);
|
||||
}) as TFunction;
|
||||
|
||||
const { history } = createHistory();
|
||||
|
||||
let didTapTwice: boolean = false;
|
||||
|
@ -275,58 +249,77 @@ export type PointerDownState = Readonly<{
|
|||
};
|
||||
}>;
|
||||
|
||||
export type ExcalidrawImperativeAPI =
|
||||
| {
|
||||
updateScene: InstanceType<typeof App>["updateScene"];
|
||||
resetScene: InstanceType<typeof App>["resetScene"];
|
||||
resetHistory: InstanceType<typeof App>["resetHistory"];
|
||||
getSceneElementsIncludingDeleted: InstanceType<
|
||||
typeof App
|
||||
>["getSceneElementsIncludingDeleted"];
|
||||
}
|
||||
| undefined;
|
||||
export type ExcalidrawImperativeAPI = {
|
||||
updateScene: InstanceType<typeof App>["updateScene"];
|
||||
resetScene: InstanceType<typeof App>["resetScene"];
|
||||
getSceneElementsIncludingDeleted: InstanceType<
|
||||
typeof App
|
||||
>["getSceneElementsIncludingDeleted"];
|
||||
history: {
|
||||
clear: InstanceType<typeof App>["resetHistory"];
|
||||
};
|
||||
setScrollToCenter: InstanceType<typeof App>["setScrollToCenter"];
|
||||
getSceneElements: InstanceType<typeof App>["getSceneElements"];
|
||||
readyPromise: ResolvablePromise<ExcalidrawImperativeAPI>;
|
||||
ready: true;
|
||||
};
|
||||
|
||||
class App extends React.Component<ExcalidrawProps, AppState> {
|
||||
canvas: HTMLCanvasElement | null = null;
|
||||
rc: RoughCanvas | null = null;
|
||||
portal: Portal;
|
||||
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
||||
unmounted: boolean = false;
|
||||
actionManager: ActionManager;
|
||||
private excalidrawRef: any;
|
||||
private socketInitializationTimer: any;
|
||||
private excalidrawContainerRef = React.createRef<HTMLDivElement>();
|
||||
|
||||
public static defaultProps: Partial<ExcalidrawProps> = {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
};
|
||||
private scene: Scene;
|
||||
|
||||
constructor(props: ExcalidrawProps) {
|
||||
super(props);
|
||||
const defaultAppState = getDefaultAppState();
|
||||
|
||||
const { width, height, offsetLeft, offsetTop, user, forwardedRef } = props;
|
||||
const {
|
||||
width = window.innerWidth,
|
||||
height = window.innerHeight,
|
||||
offsetLeft,
|
||||
offsetTop,
|
||||
excalidrawRef,
|
||||
} = props;
|
||||
this.state = {
|
||||
...defaultAppState,
|
||||
isLoading: true,
|
||||
width,
|
||||
height,
|
||||
username: user?.name || "",
|
||||
...this.getCanvasOffsets({ offsetLeft, offsetTop }),
|
||||
};
|
||||
if (forwardedRef && "current" in forwardedRef) {
|
||||
forwardedRef.current = {
|
||||
if (excalidrawRef) {
|
||||
const readyPromise =
|
||||
typeof excalidrawRef === "function"
|
||||
? resolvablePromise<ExcalidrawImperativeAPI>()
|
||||
: excalidrawRef.current!.readyPromise;
|
||||
const api: ExcalidrawImperativeAPI = {
|
||||
ready: true,
|
||||
readyPromise,
|
||||
updateScene: this.updateScene,
|
||||
resetScene: this.resetScene,
|
||||
resetHistory: this.resetHistory,
|
||||
getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted,
|
||||
};
|
||||
history: {
|
||||
clear: this.resetHistory,
|
||||
},
|
||||
setScrollToCenter: this.setScrollToCenter,
|
||||
getSceneElements: this.getSceneElements,
|
||||
} as const;
|
||||
if (typeof excalidrawRef === "function") {
|
||||
excalidrawRef(api);
|
||||
} else {
|
||||
excalidrawRef.current = api;
|
||||
}
|
||||
readyPromise.resolve(api);
|
||||
}
|
||||
this.scene = new Scene();
|
||||
this.portal = new Portal(this);
|
||||
|
||||
this.excalidrawRef = React.createRef();
|
||||
this.actionManager = new ActionManager(
|
||||
this.syncActionResult,
|
||||
() => this.state,
|
||||
|
@ -347,7 +340,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
offsetLeft,
|
||||
} = this.state;
|
||||
|
||||
const { onUsernameChange } = this.props;
|
||||
const { onCollabButtonClick } = this.props;
|
||||
const canvasScale = window.devicePixelRatio;
|
||||
|
||||
const canvasWidth = canvasDOMWidth * canvasScale;
|
||||
|
@ -356,7 +349,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
return (
|
||||
<div
|
||||
className="excalidraw"
|
||||
ref={this.excalidrawRef}
|
||||
ref={this.excalidrawContainerRef}
|
||||
style={{
|
||||
width: canvasDOMWidth,
|
||||
height: canvasDOMHeight,
|
||||
|
@ -370,12 +363,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
setAppState={this.setAppState}
|
||||
actionManager={this.actionManager}
|
||||
elements={this.scene.getElements()}
|
||||
onRoomCreate={this.openPortal}
|
||||
onRoomDestroy={this.closePortal}
|
||||
onUsernameChange={(username) => {
|
||||
onUsernameChange && onUsernameChange(username);
|
||||
this.setState({ username });
|
||||
}}
|
||||
onCollabButtonClick={onCollabButtonClick}
|
||||
onLockToggle={this.toggleLock}
|
||||
onInsertShape={(elements) =>
|
||||
this.addElementsFromPasteOrLibrary(elements)
|
||||
|
@ -383,6 +371,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
zenModeEnabled={zenModeEnabled}
|
||||
toggleZenMode={this.toggleZenMode}
|
||||
lng={getLanguage().lng}
|
||||
isCollaborating={this.props.isCollaborating || false}
|
||||
/>
|
||||
<main>
|
||||
<canvas
|
||||
|
@ -410,18 +399,14 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
);
|
||||
}
|
||||
|
||||
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
|
||||
this.lastBroadcastedOrReceivedSceneVersion = version;
|
||||
};
|
||||
|
||||
public getLastBroadcastedOrReceivedSceneVersion = () => {
|
||||
return this.lastBroadcastedOrReceivedSceneVersion;
|
||||
};
|
||||
|
||||
public getSceneElementsIncludingDeleted = () => {
|
||||
return this.scene.getElementsIncludingDeleted();
|
||||
};
|
||||
|
||||
public getSceneElements = () => {
|
||||
return this.scene.getElements();
|
||||
};
|
||||
|
||||
private syncActionResult = withBatchedUpdates(
|
||||
(actionResult: ActionResult) => {
|
||||
if (this.unmounted || actionResult === false) {
|
||||
|
@ -454,8 +439,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
...actionResult.appState,
|
||||
editingElement:
|
||||
editingElement || actionResult.appState?.editingElement || null,
|
||||
isCollaborating: state.isCollaborating,
|
||||
collaborators: state.collaborators,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
offsetTop: state.offsetTop,
|
||||
|
@ -482,7 +465,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
});
|
||||
|
||||
private onUnload = () => {
|
||||
this.destroySocketClient();
|
||||
this.onBlur();
|
||||
};
|
||||
|
||||
|
@ -499,46 +481,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
this.onSceneUpdated();
|
||||
};
|
||||
|
||||
private shouldForceLoadScene(
|
||||
scene: ResolutionType<typeof loadScene>,
|
||||
): boolean {
|
||||
if (!scene.elements.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const roomMatch = getCollaborationLinkData(window.location.href);
|
||||
|
||||
if (!roomMatch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const roomId = roomMatch[1];
|
||||
|
||||
let collabForceLoadFlag;
|
||||
try {
|
||||
collabForceLoadFlag = localStorage?.getItem(
|
||||
LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
|
||||
);
|
||||
} catch {}
|
||||
|
||||
if (collabForceLoadFlag) {
|
||||
try {
|
||||
const {
|
||||
room: previousRoom,
|
||||
timestamp,
|
||||
}: { room: string; timestamp: number } = JSON.parse(
|
||||
collabForceLoadFlag,
|
||||
);
|
||||
// if loading same room as the one previously unloaded within 15sec
|
||||
// force reload without prompting
|
||||
if (previousRoom === roomId && Date.now() - timestamp < 15000) {
|
||||
return true;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private importLibraryFromUrl = async (url: string) => {
|
||||
window.history.replaceState({}, "Excalidraw", window.location.origin);
|
||||
try {
|
||||
|
@ -569,17 +511,21 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
history.clear();
|
||||
};
|
||||
|
||||
// Completely resets scene & history.
|
||||
// Do not use for clear scene user action.
|
||||
private resetScene = withBatchedUpdates(() => {
|
||||
this.scene.replaceAllElements([]);
|
||||
this.setState({
|
||||
...getDefaultAppState(),
|
||||
appearance: this.state.appearance,
|
||||
username: this.state.username,
|
||||
});
|
||||
this.resetHistory();
|
||||
});
|
||||
/**
|
||||
* Resets scene & history.
|
||||
* ! Do not use to clear scene user action !
|
||||
*/
|
||||
private resetScene = withBatchedUpdates(
|
||||
(opts?: { resetLoadingState: boolean }) => {
|
||||
this.scene.replaceAllElements([]);
|
||||
this.setState((state) => ({
|
||||
...getDefaultAppState(),
|
||||
isLoading: opts?.resetLoadingState ? false : state.isLoading,
|
||||
appearance: this.state.appearance,
|
||||
}));
|
||||
this.resetHistory();
|
||||
},
|
||||
);
|
||||
|
||||
private initializeScene = async () => {
|
||||
if ("launchQueue" in window && "LaunchParams" in window) {
|
||||
|
@ -609,86 +555,42 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
);
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const id = searchParams.get("id");
|
||||
const jsonMatch = window.location.hash.match(
|
||||
/^#json=([0-9]+),([a-zA-Z0-9_-]+)$/,
|
||||
);
|
||||
|
||||
if (!this.state.isLoading) {
|
||||
this.setState({ isLoading: true });
|
||||
}
|
||||
|
||||
let scene = await loadScene(null, null, this.props.initialData);
|
||||
|
||||
let isCollaborationScene = !!getCollaborationLinkData(window.location.href);
|
||||
const isExternalScene = !!(id || jsonMatch || isCollaborationScene);
|
||||
|
||||
if (isExternalScene) {
|
||||
if (
|
||||
this.shouldForceLoadScene(scene) ||
|
||||
window.confirm(t("alerts.loadSceneOverridePrompt"))
|
||||
) {
|
||||
// Backwards compatibility with legacy url format
|
||||
if (id) {
|
||||
scene = await loadScene(id, null, this.props.initialData);
|
||||
} else if (jsonMatch) {
|
||||
scene = await loadScene(
|
||||
jsonMatch[1],
|
||||
jsonMatch[2],
|
||||
this.props.initialData,
|
||||
);
|
||||
}
|
||||
if (!isCollaborationScene) {
|
||||
window.history.replaceState({}, "Excalidraw", window.location.origin);
|
||||
}
|
||||
} else {
|
||||
// https://github.com/excalidraw/excalidraw/issues/1919
|
||||
if (document.hidden) {
|
||||
window.addEventListener("focus", () => this.initializeScene(), {
|
||||
once: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
isCollaborationScene = false;
|
||||
window.history.replaceState({}, "Excalidraw", window.location.origin);
|
||||
}
|
||||
let initialData = null;
|
||||
try {
|
||||
initialData = (await this.props.initialData) || null;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
if (this.state.isLoading) {
|
||||
this.setState({ isLoading: false });
|
||||
}
|
||||
const scene = restore(initialData, null);
|
||||
|
||||
if (isCollaborationScene) {
|
||||
// when joining a room we don't want user's local scene data to be merged
|
||||
// into the remote scene
|
||||
this.resetScene();
|
||||
this.initializeSocketClient({ showLoadingState: true });
|
||||
trackEvent(EVENT_SHARE, "session join");
|
||||
} else if (scene) {
|
||||
if (scene.appState) {
|
||||
scene.appState = {
|
||||
scene.appState = {
|
||||
...scene.appState,
|
||||
...calculateScrollCenter(
|
||||
scene.elements,
|
||||
{
|
||||
...scene.appState,
|
||||
...calculateScrollCenter(
|
||||
scene.elements,
|
||||
{
|
||||
...scene.appState,
|
||||
offsetTop: this.state.offsetTop,
|
||||
offsetLeft: this.state.offsetLeft,
|
||||
},
|
||||
null,
|
||||
),
|
||||
};
|
||||
}
|
||||
this.resetHistory();
|
||||
this.syncActionResult({
|
||||
...scene,
|
||||
commitToHistory: true,
|
||||
});
|
||||
}
|
||||
offsetTop: this.state.offsetTop,
|
||||
offsetLeft: this.state.offsetLeft,
|
||||
},
|
||||
null,
|
||||
),
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
const addToLibraryUrl = searchParams.get("addLibrary");
|
||||
this.resetHistory();
|
||||
this.syncActionResult({
|
||||
...scene,
|
||||
commitToHistory: true,
|
||||
});
|
||||
|
||||
const addToLibraryUrl = new URLSearchParams(window.location.search).get(
|
||||
"addLibrary",
|
||||
);
|
||||
|
||||
if (addToLibraryUrl) {
|
||||
await this.importLibraryFromUrl(addToLibraryUrl);
|
||||
|
@ -752,12 +654,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
this.setState({});
|
||||
});
|
||||
|
||||
private onHashChange = (_: HashChangeEvent) => {
|
||||
if (window.location.hash.length > 1) {
|
||||
this.initializeScene();
|
||||
}
|
||||
};
|
||||
|
||||
private removeEventListeners() {
|
||||
document.removeEventListener(EVENT.COPY, this.onCopy);
|
||||
document.removeEventListener(EVENT.PASTE, this.pasteFromClipboard);
|
||||
|
@ -775,7 +671,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
window.removeEventListener(EVENT.BLUR, this.onBlur, false);
|
||||
window.removeEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
|
||||
window.removeEventListener(EVENT.DROP, this.disableEvent, false);
|
||||
window.removeEventListener(EVENT.HASHCHANGE, this.onHashChange, false);
|
||||
|
||||
document.removeEventListener(
|
||||
EVENT.GESTURE_START,
|
||||
|
@ -792,7 +687,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
this.onGestureEnd as any,
|
||||
false,
|
||||
);
|
||||
window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
||||
}
|
||||
|
||||
private addEventListeners() {
|
||||
|
@ -811,7 +705,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
window.addEventListener(EVENT.BLUR, this.onBlur, false);
|
||||
window.addEventListener(EVENT.DRAG_OVER, this.disableEvent, false);
|
||||
window.addEventListener(EVENT.DROP, this.disableEvent, false);
|
||||
window.addEventListener(EVENT.HASHCHANGE, this.onHashChange, false);
|
||||
|
||||
// rerender text elements on font load to fix #637 && #1553
|
||||
document.fonts?.addEventListener?.("loadingdone", this.onFontLoaded);
|
||||
|
@ -832,42 +725,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
this.onGestureEnd as any,
|
||||
false,
|
||||
);
|
||||
window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
||||
}
|
||||
|
||||
private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
|
||||
if (this.state.isCollaborating && this.portal.roomId) {
|
||||
try {
|
||||
localStorage?.setItem(
|
||||
LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
|
||||
JSON.stringify({
|
||||
timestamp: Date.now(),
|
||||
room: this.portal.roomId,
|
||||
}),
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
const syncableElements = getSyncableElements(
|
||||
this.scene.getElementsIncludingDeleted(),
|
||||
);
|
||||
if (
|
||||
this.state.isCollaborating &&
|
||||
!isSavedToFirebase(this.portal, syncableElements)
|
||||
) {
|
||||
// this won't run in time if user decides to leave the site, but
|
||||
// the purpose is to run in immediately after user decides to stay
|
||||
this.saveCollabRoomToFirebase(syncableElements);
|
||||
|
||||
event.preventDefault();
|
||||
// NOTE: modern browsers no longer allow showing a custom message here
|
||||
event.returnValue = "";
|
||||
}
|
||||
});
|
||||
|
||||
queueBroadcastAllElements = throttle(() => {
|
||||
this.portal.broadcastScene(SCENE.UPDATE, /* syncAll */ true);
|
||||
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
||||
|
||||
componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) {
|
||||
if (
|
||||
prevProps.width !== this.props.width ||
|
||||
|
@ -878,8 +737,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
prevProps.offsetTop !== this.props.offsetTop)
|
||||
) {
|
||||
this.setState({
|
||||
width: this.props.width,
|
||||
height: this.props.height,
|
||||
width: this.props.width ?? window.innerWidth,
|
||||
height: this.props.height ?? window.innerHeight,
|
||||
...this.getCanvasOffsets(this.props),
|
||||
});
|
||||
}
|
||||
|
@ -990,19 +849,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
this.setState({ scrolledOutside });
|
||||
}
|
||||
|
||||
if (
|
||||
getSceneVersion(this.scene.getElementsIncludingDeleted()) >
|
||||
this.lastBroadcastedOrReceivedSceneVersion
|
||||
) {
|
||||
this.portal.broadcastScene(SCENE.UPDATE, /* syncAll */ false);
|
||||
this.queueBroadcastAllElements();
|
||||
}
|
||||
|
||||
history.record(this.state, this.scene.getElementsIncludingDeleted());
|
||||
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(this.scene.getElementsIncludingDeleted(), this.state);
|
||||
}
|
||||
this.props.onChange?.(this.scene.getElementsIncludingDeleted(), this.state);
|
||||
}
|
||||
|
||||
// Copy/paste
|
||||
|
@ -1254,31 +1103,6 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
gesture.pointers.delete(event.pointerId);
|
||||
};
|
||||
|
||||
openPortal = async () => {
|
||||
window.history.pushState(
|
||||
{},
|
||||
"Excalidraw",
|
||||
await generateCollaborationLink(),
|
||||
);
|
||||
// remove deleted elements from elements array & history to ensure we don't
|
||||
// expose potentially sensitive user data in case user manually deletes
|
||||
// existing elements (or clears scene), which would otherwise be persisted
|
||||
// to database even if deleted before creating the room.
|
||||
history.clear();
|
||||
history.resumeRecording();
|
||||
this.scene.replaceAllElements(this.scene.getElements());
|
||||
|
||||
await this.initializeSocketClient({ showLoadingState: false });
|
||||
trackEvent(EVENT_SHARE, "session start");
|
||||
};
|
||||
|
||||
closePortal = () => {
|
||||
this.saveCollabRoomToFirebase();
|
||||
window.history.pushState({}, "Excalidraw", window.location.origin);
|
||||
this.destroySocketClient();
|
||||
trackEvent(EVENT_SHARE, "session end");
|
||||
};
|
||||
|
||||
toggleLock = () => {
|
||||
this.setState((prevState) => {
|
||||
trackEvent(EVENT_SHAPE, "lock", !prevState.elementLocked ? "on" : "off");
|
||||
|
@ -1313,202 +1137,26 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
});
|
||||
};
|
||||
|
||||
private handleRemoteSceneUpdate = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
{
|
||||
init = false,
|
||||
initFromSnapshot = false,
|
||||
}: { init?: boolean; initFromSnapshot?: boolean } = {},
|
||||
) => {
|
||||
if (init) {
|
||||
public updateScene = withBatchedUpdates((sceneData: SceneData) => {
|
||||
if (sceneData.commitToHistory) {
|
||||
history.resumeRecording();
|
||||
}
|
||||
|
||||
if (init || initFromSnapshot) {
|
||||
this.setScrollToCenter(elements);
|
||||
}
|
||||
const newElements = this.portal.reconcileElements(elements);
|
||||
|
||||
// Avoid broadcasting to the rest of the collaborators the scene
|
||||
// we just received!
|
||||
// Note: this needs to be set before updating the scene as it
|
||||
// syncronously calls render.
|
||||
this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements));
|
||||
|
||||
this.updateScene({ elements: 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.
|
||||
this.resetHistory();
|
||||
|
||||
if (!this.portal.socketInitialized && !initFromSnapshot) {
|
||||
this.initializeSocket();
|
||||
}
|
||||
};
|
||||
|
||||
private destroySocketClient = () => {
|
||||
this.setState({
|
||||
isCollaborating: false,
|
||||
collaborators: new Map(),
|
||||
});
|
||||
this.portal.close();
|
||||
};
|
||||
|
||||
public updateScene = withBatchedUpdates(
|
||||
(sceneData: {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
appState?: AppState;
|
||||
}) => {
|
||||
// currently we only support syncing background color
|
||||
if (sceneData.appState?.viewBackgroundColor) {
|
||||
this.setState({
|
||||
viewBackgroundColor: sceneData.appState.viewBackgroundColor,
|
||||
});
|
||||
}
|
||||
|
||||
this.scene.replaceAllElements(sceneData.elements);
|
||||
},
|
||||
);
|
||||
|
||||
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;
|
||||
}) => {
|
||||
if (this.portal.socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
const roomMatch = getCollaborationLinkData(window.location.href);
|
||||
if (roomMatch) {
|
||||
const roomId = roomMatch[1];
|
||||
const roomKey = roomMatch[2];
|
||||
|
||||
// fallback in case you're not alone in the room but still don't receive
|
||||
// initial SCENE_UPDATE message
|
||||
this.socketInitializationTimer = setTimeout(
|
||||
this.initializeSocket,
|
||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||
);
|
||||
|
||||
const { default: socketIOClient }: any = await import("socket.io-client");
|
||||
|
||||
this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey);
|
||||
|
||||
// All socket listeners are moving to Portal
|
||||
this.portal.socket!.on(
|
||||
"client-broadcast",
|
||||
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
|
||||
if (!this.portal.roomKey) {
|
||||
return;
|
||||
}
|
||||
const decryptedData = await decryptAESGEM(
|
||||
encryptedData,
|
||||
this.portal.roomKey,
|
||||
iv,
|
||||
);
|
||||
|
||||
switch (decryptedData.type) {
|
||||
case "INVALID_RESPONSE":
|
||||
return;
|
||||
case SCENE.INIT: {
|
||||
if (!this.portal.socketInitialized) {
|
||||
const remoteElements = decryptedData.payload.elements;
|
||||
this.handleRemoteSceneUpdate(remoteElements, { init: true });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SCENE.UPDATE:
|
||||
this.handleRemoteSceneUpdate(decryptedData.payload.elements);
|
||||
break;
|
||||
case "MOUSE_LOCATION": {
|
||||
const {
|
||||
socketId,
|
||||
pointer,
|
||||
button,
|
||||
username,
|
||||
selectedElementIds,
|
||||
} = decryptedData.payload;
|
||||
// NOTE purposefully mutating collaborators map in case of
|
||||
// pointer updates so as not to trigger LayerUI rerender
|
||||
this.setState((state) => {
|
||||
if (!state.collaborators.has(socketId)) {
|
||||
state.collaborators.set(socketId, {});
|
||||
}
|
||||
const user = state.collaborators.get(socketId)!;
|
||||
user.pointer = pointer;
|
||||
user.button = button;
|
||||
user.selectedElementIds = selectedElementIds;
|
||||
user.username = username;
|
||||
state.collaborators.set(socketId, user);
|
||||
return state;
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
this.portal.socket!.on("first-in-room", () => {
|
||||
if (this.portal.socket) {
|
||||
this.portal.socket.off("first-in-room");
|
||||
}
|
||||
this.initializeSocket();
|
||||
});
|
||||
|
||||
// currently we only support syncing background color
|
||||
if (sceneData.appState?.viewBackgroundColor) {
|
||||
this.setState({
|
||||
isCollaborating: true,
|
||||
isLoading: opts.showLoadingState ? true : this.state.isLoading,
|
||||
viewBackgroundColor: sceneData.appState.viewBackgroundColor,
|
||||
});
|
||||
|
||||
try {
|
||||
const elements = await loadFromFirebase(roomId, roomKey);
|
||||
if (elements) {
|
||||
this.handleRemoteSceneUpdate(elements, { initFromSnapshot: true });
|
||||
}
|
||||
} catch (error) {
|
||||
// log the error and move on. other peers will sync us the scene.
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Portal-only
|
||||
setCollaborators(sockets: string[]) {
|
||||
this.setState((state) => {
|
||||
const collaborators: typeof state.collaborators = new Map();
|
||||
for (const socketId of sockets) {
|
||||
if (state.collaborators.has(socketId)) {
|
||||
collaborators.set(socketId, state.collaborators.get(socketId)!);
|
||||
} else {
|
||||
collaborators.set(socketId, {});
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
collaborators,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
saveCollabRoomToFirebase = async (
|
||||
syncableElements: ExcalidrawElement[] = getSyncableElements(
|
||||
this.scene.getElementsIncludingDeleted(),
|
||||
),
|
||||
) => {
|
||||
try {
|
||||
await saveToFirebase(this.portal, syncableElements);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (sceneData.elements) {
|
||||
this.scene.replaceAllElements(sceneData.elements);
|
||||
}
|
||||
};
|
||||
|
||||
if (sceneData.collaborators) {
|
||||
this.setState({ collaborators: sceneData.collaborators });
|
||||
}
|
||||
});
|
||||
|
||||
private onSceneUpdated = () => {
|
||||
this.setState({});
|
||||
|
@ -3989,15 +3637,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
|
||||
if (isNaN(pointer.x) || isNaN(pointer.y)) {
|
||||
// sometimes the pointer goes off screen
|
||||
return;
|
||||
}
|
||||
this.portal.socket &&
|
||||
// do not broadcast when more than 1 pointer since that shows flickering on the other side
|
||||
gesture.pointers.size < 2 &&
|
||||
this.portal.broadcastMouseLocation({
|
||||
pointer,
|
||||
button,
|
||||
});
|
||||
|
||||
this.props.onPointerUpdate?.({
|
||||
pointer,
|
||||
button,
|
||||
pointersMap: gesture.pointers,
|
||||
});
|
||||
};
|
||||
|
||||
private resetShouldCacheIgnoreZoomDebounced = debounce(() => {
|
||||
|
@ -4017,8 +3663,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
|
|||
offsetTop: offsets.offsetTop,
|
||||
};
|
||||
}
|
||||
if (this.excalidrawRef?.current) {
|
||||
const parentElement = this.excalidrawRef.current.parentElement;
|
||||
if (this.excalidrawContainerRef?.current?.parentElement) {
|
||||
const parentElement = this.excalidrawContainerRef.current.parentElement;
|
||||
const { left, top } = parentElement.getBoundingClientRect();
|
||||
return {
|
||||
offsetLeft:
|
||||
|
@ -4048,6 +3694,9 @@ declare global {
|
|||
history: SceneHistory;
|
||||
app: InstanceType<typeof App>;
|
||||
library: typeof Library;
|
||||
collab: InstanceType<
|
||||
typeof import("../excalidraw-app/collab/CollabWrapper").default
|
||||
>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -4056,10 +3705,11 @@ if (
|
|||
process.env.NODE_ENV === ENV.TEST ||
|
||||
process.env.NODE_ENV === ENV.DEVELOPMENT
|
||||
) {
|
||||
window.h = {} as Window["h"];
|
||||
window.h = window.h || ({} as Window["h"]);
|
||||
|
||||
Object.defineProperties(window.h, {
|
||||
elements: {
|
||||
configurable: true,
|
||||
get() {
|
||||
return this.app.scene.getElementsIncludingDeleted();
|
||||
},
|
||||
|
@ -4068,9 +3718,11 @@ if (
|
|||
},
|
||||
},
|
||||
history: {
|
||||
configurable: true,
|
||||
get: () => history,
|
||||
},
|
||||
library: {
|
||||
configurable: true,
|
||||
value: Library,
|
||||
},
|
||||
});
|
||||
|
|
29
src/components/CollabButton.scss
Normal file
29
src/components/CollabButton.scss
Normal file
|
@ -0,0 +1,29 @@
|
|||
@import "../css/_variables";
|
||||
|
||||
.excalidraw {
|
||||
.CollabButton.is-collaborating {
|
||||
background-color: var(--button-special-active-background-color);
|
||||
|
||||
.ToolIcon__icon svg {
|
||||
color: var(--icon-green-fill-color);
|
||||
}
|
||||
}
|
||||
|
||||
.CollabButton-collaborators {
|
||||
:root[dir="ltr"] & {
|
||||
right: -5px;
|
||||
}
|
||||
:root[dir="rtl"] & {
|
||||
left: -5px;
|
||||
}
|
||||
min-width: 1em;
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
padding: 3px;
|
||||
border-radius: 50%;
|
||||
background-color: $oc-green-6;
|
||||
color: $oc-white;
|
||||
font-size: 0.7em;
|
||||
font-family: var(--ui-font);
|
||||
}
|
||||
}
|
44
src/components/CollabButton.tsx
Normal file
44
src/components/CollabButton.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { users } from "./icons";
|
||||
|
||||
import "./CollabButton.scss";
|
||||
import { EVENT_DIALOG, trackEvent } from "../analytics";
|
||||
|
||||
const CollabButton = ({
|
||||
isCollaborating,
|
||||
collaboratorCount,
|
||||
onClick,
|
||||
}: {
|
||||
isCollaborating: boolean;
|
||||
collaboratorCount: number;
|
||||
onClick: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<ToolButton
|
||||
className={clsx("CollabButton", {
|
||||
"is-collaborating": isCollaborating,
|
||||
})}
|
||||
onClick={() => {
|
||||
trackEvent(EVENT_DIALOG, "collaboration");
|
||||
onClick();
|
||||
}}
|
||||
icon={users}
|
||||
type="button"
|
||||
title={t("buttons.roomDialog")}
|
||||
aria-label={t("buttons.roomDialog")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
>
|
||||
{collaboratorCount > 0 && (
|
||||
<div className="CollabButton-collaborators">{collaboratorCount}</div>
|
||||
)}
|
||||
</ToolButton>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollabButton;
|
|
@ -28,7 +28,7 @@ import { ExportType } from "../scene/types";
|
|||
import { MobileMenu } from "./MobileMenu";
|
||||
import { ZoomActions, SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||
import { Section } from "./Section";
|
||||
import { RoomDialog } from "./RoomDialog";
|
||||
import CollabButton from "./CollabButton";
|
||||
import { ErrorDialog } from "./ErrorDialog";
|
||||
import { ShortcutsDialog } from "./ShortcutsDialog";
|
||||
import { LoadingMessage } from "./LoadingMessage";
|
||||
|
@ -58,14 +58,13 @@ interface LayerUIProps {
|
|||
canvas: HTMLCanvasElement | null;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
onRoomCreate: () => void;
|
||||
onUsernameChange: (username: string) => void;
|
||||
onRoomDestroy: () => void;
|
||||
onCollabButtonClick?: () => void;
|
||||
onLockToggle: () => void;
|
||||
onInsertShape: (elements: LibraryItem) => void;
|
||||
zenModeEnabled: boolean;
|
||||
toggleZenMode: () => void;
|
||||
lng: string;
|
||||
isCollaborating: boolean;
|
||||
}
|
||||
|
||||
const useOnClickOutside = (
|
||||
|
@ -299,13 +298,12 @@ const LayerUI = ({
|
|||
setAppState,
|
||||
canvas,
|
||||
elements,
|
||||
onRoomCreate,
|
||||
onUsernameChange,
|
||||
onRoomDestroy,
|
||||
onCollabButtonClick,
|
||||
onLockToggle,
|
||||
onInsertShape,
|
||||
zenModeEnabled,
|
||||
toggleZenMode,
|
||||
isCollaborating,
|
||||
}: LayerUIProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
|
@ -400,17 +398,13 @@ const LayerUI = ({
|
|||
{actionManager.renderAction("saveAsScene")}
|
||||
{renderExportDialog()}
|
||||
{actionManager.renderAction("clearCanvas")}
|
||||
<RoomDialog
|
||||
isCollaborating={appState.isCollaborating}
|
||||
collaboratorCount={appState.collaborators.size}
|
||||
username={appState.username}
|
||||
onUsernameChange={onUsernameChange}
|
||||
onRoomCreate={onRoomCreate}
|
||||
onRoomDestroy={onRoomDestroy}
|
||||
setErrorMessage={(message: string) =>
|
||||
setAppState({ errorMessage: message })
|
||||
}
|
||||
/>
|
||||
{onCollabButtonClick && (
|
||||
<CollabButton
|
||||
isCollaborating={isCollaborating}
|
||||
collaboratorCount={appState.collaborators.size}
|
||||
onClick={onCollabButtonClick}
|
||||
/>
|
||||
)}
|
||||
</Stack.Row>
|
||||
<BackgroundPickerAndDarkModeToggle
|
||||
actionManager={actionManager}
|
||||
|
@ -602,11 +596,10 @@ const LayerUI = ({
|
|||
libraryMenu={libraryMenu}
|
||||
exportButton={renderExportDialog()}
|
||||
setAppState={setAppState}
|
||||
onUsernameChange={onUsernameChange}
|
||||
onRoomCreate={onRoomCreate}
|
||||
onRoomDestroy={onRoomDestroy}
|
||||
onCollabButtonClick={onCollabButtonClick}
|
||||
onLockToggle={onLockToggle}
|
||||
canvas={canvas}
|
||||
isCollaborating={isCollaborating}
|
||||
/>
|
||||
) : (
|
||||
<div className="layer-ui__wrapper">
|
||||
|
|
|
@ -12,7 +12,7 @@ import { HintViewer } from "./HintViewer";
|
|||
import { calculateScrollCenter } from "../scene";
|
||||
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||
import { Section } from "./Section";
|
||||
import { RoomDialog } from "./RoomDialog";
|
||||
import CollabButton from "./CollabButton";
|
||||
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
||||
import { LockIcon } from "./LockIcon";
|
||||
import { LoadingMessage } from "./LoadingMessage";
|
||||
|
@ -27,11 +27,10 @@ type MobileMenuProps = {
|
|||
setAppState: React.Component<any, AppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
libraryMenu: JSX.Element | null;
|
||||
onRoomCreate: () => void;
|
||||
onUsernameChange: (username: string) => void;
|
||||
onRoomDestroy: () => void;
|
||||
onCollabButtonClick?: () => void;
|
||||
onLockToggle: () => void;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
isCollaborating: boolean;
|
||||
};
|
||||
|
||||
export const MobileMenu = ({
|
||||
|
@ -41,11 +40,10 @@ export const MobileMenu = ({
|
|||
actionManager,
|
||||
exportButton,
|
||||
setAppState,
|
||||
onRoomCreate,
|
||||
onUsernameChange,
|
||||
onRoomDestroy,
|
||||
onCollabButtonClick,
|
||||
onLockToggle,
|
||||
canvas,
|
||||
isCollaborating,
|
||||
}: MobileMenuProps) => (
|
||||
<>
|
||||
{appState.isLoading && <LoadingMessage />}
|
||||
|
@ -94,17 +92,13 @@ export const MobileMenu = ({
|
|||
{actionManager.renderAction("saveAsScene")}
|
||||
{exportButton}
|
||||
{actionManager.renderAction("clearCanvas")}
|
||||
<RoomDialog
|
||||
isCollaborating={appState.isCollaborating}
|
||||
collaboratorCount={appState.collaborators.size}
|
||||
username={appState.username}
|
||||
onUsernameChange={onUsernameChange}
|
||||
onRoomCreate={onRoomCreate}
|
||||
onRoomDestroy={onRoomDestroy}
|
||||
setErrorMessage={(message: string) =>
|
||||
setAppState({ errorMessage: message })
|
||||
}
|
||||
/>
|
||||
{onCollabButtonClick && (
|
||||
<CollabButton
|
||||
isCollaborating={isCollaborating}
|
||||
collaboratorCount={appState.collaborators.size}
|
||||
onClick={onCollabButtonClick}
|
||||
/>
|
||||
)}
|
||||
<BackgroundPickerAndDarkModeToggle
|
||||
actionManager={actionManager}
|
||||
appState={appState}
|
||||
|
|
|
@ -1,214 +0,0 @@
|
|||
import { encryptAESGEM, SocketUpdateDataSource } from "../data";
|
||||
|
||||
import { SocketUpdateData } from "../types";
|
||||
import { BROADCAST, SCENE } from "../constants";
|
||||
import App from "./App";
|
||||
import {
|
||||
getElementMap,
|
||||
getSceneVersion,
|
||||
getSyncableElements,
|
||||
} from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
|
||||
class Portal {
|
||||
app: App;
|
||||
socket: SocketIOClient.Socket | null = null;
|
||||
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
|
||||
roomId: string | null = null;
|
||||
roomKey: string | null = null;
|
||||
broadcastedElementVersions: Map<string, number> = new Map();
|
||||
|
||||
constructor(app: App) {
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
open(socket: SocketIOClient.Socket, id: string, key: string) {
|
||||
this.socket = socket;
|
||||
this.roomId = id;
|
||||
this.roomKey = key;
|
||||
|
||||
// Initialize socket listeners (moving from App)
|
||||
this.socket.on("init-room", () => {
|
||||
if (this.socket) {
|
||||
this.socket.emit("join-room", this.roomId);
|
||||
}
|
||||
});
|
||||
this.socket.on("new-user", async (_socketId: string) => {
|
||||
this.broadcastScene(SCENE.INIT, /* syncAll */ true);
|
||||
});
|
||||
this.socket.on("room-user-change", (clients: string[]) => {
|
||||
this.app.setCollaborators(clients);
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.socket) {
|
||||
return;
|
||||
}
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
this.roomId = null;
|
||||
this.roomKey = null;
|
||||
this.socketInitialized = false;
|
||||
this.broadcastedElementVersions = new Map();
|
||||
}
|
||||
|
||||
isOpen() {
|
||||
return !!(
|
||||
this.socketInitialized &&
|
||||
this.socket &&
|
||||
this.roomId &&
|
||||
this.roomKey
|
||||
);
|
||||
}
|
||||
|
||||
async _broadcastSocketData(
|
||||
data: SocketUpdateData,
|
||||
volatile: boolean = false,
|
||||
) {
|
||||
if (this.isOpen()) {
|
||||
const json = JSON.stringify(data);
|
||||
const encoded = new TextEncoder().encode(json);
|
||||
const encrypted = await encryptAESGEM(encoded, this.roomKey!);
|
||||
this.socket!.emit(
|
||||
volatile ? BROADCAST.SERVER_VOLATILE : BROADCAST.SERVER,
|
||||
this.roomId,
|
||||
encrypted.data,
|
||||
encrypted.iv,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
broadcastScene = async (
|
||||
sceneType: SCENE.INIT | SCENE.UPDATE,
|
||||
syncAll: boolean,
|
||||
) => {
|
||||
if (sceneType === SCENE.INIT && !syncAll) {
|
||||
throw new Error("syncAll must be true when sending SCENE.INIT");
|
||||
}
|
||||
|
||||
let syncableElements = getSyncableElements(
|
||||
this.app.getSceneElementsIncludingDeleted(),
|
||||
);
|
||||
|
||||
if (!syncAll) {
|
||||
// sync out only the elements we think we need to to save bandwidth.
|
||||
// periodically we'll resync the whole thing to make sure no one diverges
|
||||
// due to a dropped message (server goes down etc).
|
||||
syncableElements = syncableElements.filter(
|
||||
(syncableElement) =>
|
||||
!this.broadcastedElementVersions.has(syncableElement.id) ||
|
||||
syncableElement.version >
|
||||
this.broadcastedElementVersions.get(syncableElement.id)!,
|
||||
);
|
||||
}
|
||||
|
||||
const data: SocketUpdateDataSource[typeof sceneType] = {
|
||||
type: sceneType,
|
||||
payload: {
|
||||
elements: syncableElements,
|
||||
},
|
||||
};
|
||||
const currentVersion = this.app.getLastBroadcastedOrReceivedSceneVersion();
|
||||
const newVersion = Math.max(
|
||||
currentVersion,
|
||||
getSceneVersion(this.app.getSceneElementsIncludingDeleted()),
|
||||
);
|
||||
this.app.setLastBroadcastedOrReceivedSceneVersion(newVersion);
|
||||
|
||||
for (const syncableElement of syncableElements) {
|
||||
this.broadcastedElementVersions.set(
|
||||
syncableElement.id,
|
||||
syncableElement.version,
|
||||
);
|
||||
}
|
||||
|
||||
const broadcastPromise = this._broadcastSocketData(
|
||||
data as SocketUpdateData,
|
||||
);
|
||||
|
||||
if (syncAll && this.app.state.isCollaborating) {
|
||||
await Promise.all([
|
||||
broadcastPromise,
|
||||
this.app.saveCollabRoomToFirebase(syncableElements),
|
||||
]);
|
||||
} else {
|
||||
await broadcastPromise;
|
||||
}
|
||||
};
|
||||
|
||||
broadcastMouseLocation = (payload: {
|
||||
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
|
||||
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
|
||||
}) => {
|
||||
if (this.socket?.id) {
|
||||
const data: SocketUpdateDataSource["MOUSE_LOCATION"] = {
|
||||
type: "MOUSE_LOCATION",
|
||||
payload: {
|
||||
socketId: this.socket.id,
|
||||
pointer: payload.pointer,
|
||||
button: payload.button || "up",
|
||||
selectedElementIds: this.app.state.selectedElementIds,
|
||||
username: this.app.state.username,
|
||||
},
|
||||
};
|
||||
return this._broadcastSocketData(
|
||||
data as SocketUpdateData,
|
||||
true, // volatile
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
reconcileElements = (sceneElements: readonly ExcalidrawElement[]) => {
|
||||
const currentElements = this.app.getSceneElementsIncludingDeleted();
|
||||
// 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 = sceneElements
|
||||
.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.app.state.editingElement?.id ||
|
||||
element.id === this.app.state.resizingElement?.id ||
|
||||
element.id === this.app.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 sceneElements>)
|
||||
// add local elements that weren't deleted or on remote
|
||||
.concat(...Object.values(localElementMap));
|
||||
return newElements;
|
||||
};
|
||||
}
|
||||
|
||||
export default Portal;
|
|
@ -1,89 +0,0 @@
|
|||
@import "../css/_variables";
|
||||
|
||||
.excalidraw {
|
||||
.RoomDialog-modalButton.is-collaborating {
|
||||
background-color: var(--button-special-active-background-color);
|
||||
|
||||
.ToolIcon__icon svg {
|
||||
color: var(--icon-green-fill-color);
|
||||
}
|
||||
}
|
||||
|
||||
.RoomDialog-modalButton-collaborators {
|
||||
min-width: 1em;
|
||||
position: absolute;
|
||||
:root[dir="ltr"] & {
|
||||
right: -5px;
|
||||
}
|
||||
:root[dir="rtl"] & {
|
||||
left: -5px;
|
||||
}
|
||||
bottom: -5px;
|
||||
padding: 3px;
|
||||
border-radius: 50%;
|
||||
background-color: $oc-green-6;
|
||||
color: $oc-white;
|
||||
font-size: 0.7em;
|
||||
font-family: var(--ui-font);
|
||||
}
|
||||
|
||||
.RoomDialog-linkContainer {
|
||||
display: flex;
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
.RoomDialog-link {
|
||||
color: var(--text-color-primary);
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
margin-inline-start: 1em;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
height: 2.5rem;
|
||||
line-height: 2.5rem;
|
||||
padding: 0 0.5rem;
|
||||
white-space: nowrap;
|
||||
border-radius: var(--space-factor);
|
||||
background-color: var(--button-gray-1);
|
||||
}
|
||||
|
||||
.RoomDialog-emoji {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.RoomDialog-usernameContainer {
|
||||
display: flex;
|
||||
margin: 1.5em 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.RoomDialog-username {
|
||||
background-color: var(--input-background-color);
|
||||
border-color: var(--input-border-color);
|
||||
appearance: none;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
margin-inline-start: 1em;
|
||||
height: 2.5rem;
|
||||
font-size: 1em;
|
||||
line-height: 1.5;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.RoomDialog-sessionStartButtonContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.Modal .RoomDialog-stopSession {
|
||||
background-color: var(--button-destructive-background-color);
|
||||
|
||||
.ToolIcon__label,
|
||||
.ToolIcon__icon svg {
|
||||
color: var(--button-destructive-color);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,202 +0,0 @@
|
|||
import clsx from "clsx";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { EVENT_DIALOG, EVENT_SHARE, trackEvent } from "../analytics";
|
||||
import { copyTextToSystemClipboard } from "../clipboard";
|
||||
import { t } from "../i18n";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { KEYS } from "../keys";
|
||||
import { AppState } from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { clipboard, start, stop, users } from "./icons";
|
||||
import "./RoomDialog.scss";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
const RoomModal = ({
|
||||
activeRoomLink,
|
||||
username,
|
||||
onUsernameChange,
|
||||
onRoomCreate,
|
||||
onRoomDestroy,
|
||||
onPressingEnter,
|
||||
setErrorMessage,
|
||||
}: {
|
||||
activeRoomLink: string;
|
||||
username: string;
|
||||
onUsernameChange: (username: string) => void;
|
||||
onRoomCreate: () => void;
|
||||
onRoomDestroy: () => void;
|
||||
onPressingEnter: () => void;
|
||||
setErrorMessage: (message: string) => void;
|
||||
}) => {
|
||||
const roomLinkInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const copyRoomLink = async () => {
|
||||
try {
|
||||
await copyTextToSystemClipboard(activeRoomLink);
|
||||
trackEvent(EVENT_SHARE, "copy link");
|
||||
} catch (error) {
|
||||
setErrorMessage(error.message);
|
||||
}
|
||||
if (roomLinkInput.current) {
|
||||
roomLinkInput.current.select();
|
||||
}
|
||||
};
|
||||
const selectInput = (event: React.MouseEvent<HTMLInputElement>) => {
|
||||
if (event.target !== document.activeElement) {
|
||||
event.preventDefault();
|
||||
(event.target as HTMLInputElement).select();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="RoomDialog-modal">
|
||||
{!activeRoomLink && (
|
||||
<>
|
||||
<p>{t("roomDialog.desc_intro")}</p>
|
||||
<p>{`🔒 ${t("roomDialog.desc_privacy")}`}</p>
|
||||
<div className="RoomDialog-sessionStartButtonContainer">
|
||||
<ToolButton
|
||||
className="RoomDialog-startSession"
|
||||
type="button"
|
||||
icon={start}
|
||||
title={t("roomDialog.button_startSession")}
|
||||
aria-label={t("roomDialog.button_startSession")}
|
||||
showAriaLabel={true}
|
||||
onClick={onRoomCreate}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{activeRoomLink && (
|
||||
<>
|
||||
<p>{t("roomDialog.desc_inProgressIntro")}</p>
|
||||
<p>{t("roomDialog.desc_shareLink")}</p>
|
||||
<div className="RoomDialog-linkContainer">
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={clipboard}
|
||||
title={t("labels.copy")}
|
||||
aria-label={t("labels.copy")}
|
||||
onClick={copyRoomLink}
|
||||
/>
|
||||
<input
|
||||
value={activeRoomLink}
|
||||
readOnly={true}
|
||||
className="RoomDialog-link"
|
||||
ref={roomLinkInput}
|
||||
onPointerDown={selectInput}
|
||||
/>
|
||||
</div>
|
||||
<div className="RoomDialog-usernameContainer">
|
||||
<label className="RoomDialog-usernameLabel" htmlFor="username">
|
||||
{t("labels.yourName")}
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
value={username || ""}
|
||||
className="RoomDialog-username TextInput"
|
||||
onChange={(event) => onUsernameChange(event.target.value)}
|
||||
onBlur={() => trackEvent(EVENT_SHARE, "name")}
|
||||
onKeyPress={(event) =>
|
||||
event.key === KEYS.ENTER && onPressingEnter()
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p>
|
||||
<span role="img" aria-hidden="true" className="RoomDialog-emoji">
|
||||
{"🔒"}
|
||||
</span>{" "}
|
||||
{t("roomDialog.desc_privacy")}
|
||||
</p>
|
||||
<p>{t("roomDialog.desc_exitSession")}</p>
|
||||
<div className="RoomDialog-sessionStartButtonContainer">
|
||||
<ToolButton
|
||||
className="RoomDialog-stopSession"
|
||||
type="button"
|
||||
icon={stop}
|
||||
title={t("roomDialog.button_stopSession")}
|
||||
aria-label={t("roomDialog.button_stopSession")}
|
||||
showAriaLabel={true}
|
||||
onClick={onRoomDestroy}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const RoomDialog = ({
|
||||
isCollaborating,
|
||||
collaboratorCount,
|
||||
username,
|
||||
onUsernameChange,
|
||||
onRoomCreate,
|
||||
onRoomDestroy,
|
||||
setErrorMessage,
|
||||
}: {
|
||||
isCollaborating: AppState["isCollaborating"];
|
||||
collaboratorCount: number;
|
||||
username: string;
|
||||
onUsernameChange: (username: string) => void;
|
||||
onRoomCreate: () => void;
|
||||
onRoomDestroy: () => void;
|
||||
setErrorMessage: (message: string) => void;
|
||||
}) => {
|
||||
const [modalIsShown, setModalIsShown] = useState(false);
|
||||
const [activeRoomLink, setActiveRoomLink] = useState("");
|
||||
|
||||
const triggerButton = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setModalIsShown(false);
|
||||
triggerButton.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveRoomLink(isCollaborating ? window.location.href : "");
|
||||
}, [isCollaborating]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ToolButton
|
||||
className={clsx("RoomDialog-modalButton", {
|
||||
"is-collaborating": isCollaborating,
|
||||
})}
|
||||
onClick={() => {
|
||||
trackEvent(EVENT_DIALOG, "collaboration");
|
||||
setModalIsShown(true);
|
||||
}}
|
||||
icon={users}
|
||||
type="button"
|
||||
title={t("buttons.roomDialog")}
|
||||
aria-label={t("buttons.roomDialog")}
|
||||
showAriaLabel={useIsMobile()}
|
||||
ref={triggerButton}
|
||||
>
|
||||
{collaboratorCount > 0 && (
|
||||
<div className="RoomDialog-modalButton-collaborators">
|
||||
{collaboratorCount}
|
||||
</div>
|
||||
)}
|
||||
</ToolButton>
|
||||
{modalIsShown && (
|
||||
<Dialog
|
||||
maxWidth={800}
|
||||
onCloseRequest={handleClose}
|
||||
title={t("labels.createRoom")}
|
||||
>
|
||||
<RoomModal
|
||||
activeRoomLink={activeRoomLink}
|
||||
username={username}
|
||||
onUsernameChange={onUsernameChange}
|
||||
onRoomCreate={onRoomCreate}
|
||||
onRoomDestroy={onRoomDestroy}
|
||||
onPressingEnter={handleClose}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue