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
476
src/excalidraw-app/collab/CollabWrapper.tsx
Normal file
476
src/excalidraw-app/collab/CollabWrapper.tsx
Normal file
|
@ -0,0 +1,476 @@
|
|||
import React, { PureComponent } from "react";
|
||||
import throttle from "lodash.throttle";
|
||||
|
||||
import { ENV, EVENT } from "../../constants";
|
||||
|
||||
import {
|
||||
decryptAESGEM,
|
||||
SocketUpdateDataSource,
|
||||
getCollaborationLinkData,
|
||||
generateCollaborationLink,
|
||||
SOCKET_SERVER,
|
||||
} from "../data";
|
||||
import { isSavedToFirebase, saveToFirebase } from "../data/firebase";
|
||||
|
||||
import Portal from "./Portal";
|
||||
import { AppState, Collaborator, Gesture } from "../../types";
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import {
|
||||
importUsernameFromLocalStorage,
|
||||
saveUsernameToLocalStorage,
|
||||
STORAGE_KEYS,
|
||||
} from "../data/localStorage";
|
||||
import { resolvablePromise, withBatchedUpdates } from "../../utils";
|
||||
import {
|
||||
getSceneVersion,
|
||||
getSyncableElements,
|
||||
} from "../../packages/excalidraw/index";
|
||||
import RoomDialog from "./RoomDialog";
|
||||
import { ErrorDialog } from "../../components/ErrorDialog";
|
||||
import { ImportedDataState } from "../../data/types";
|
||||
import { ExcalidrawImperativeAPI } from "../../components/App";
|
||||
import {
|
||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||
SCENE,
|
||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||
} from "../app_constants";
|
||||
import { EVENT_SHARE, trackEvent } from "../../analytics";
|
||||
|
||||
interface CollabState {
|
||||
isCollaborating: boolean;
|
||||
modalIsShown: boolean;
|
||||
errorMessage: string;
|
||||
username: string;
|
||||
activeRoomLink: string;
|
||||
}
|
||||
|
||||
type CollabInstance = InstanceType<typeof CollabWrapper>;
|
||||
|
||||
export interface CollabAPI {
|
||||
isCollaborating: CollabState["isCollaborating"];
|
||||
username: CollabState["username"];
|
||||
onPointerUpdate: CollabInstance["onPointerUpdate"];
|
||||
initializeSocketClient: CollabInstance["initializeSocketClient"];
|
||||
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
|
||||
broadcastElements: CollabInstance["broadcastElements"];
|
||||
}
|
||||
|
||||
type ReconciledElements = readonly ExcalidrawElement[] & {
|
||||
_brand: "reconciledElements";
|
||||
};
|
||||
|
||||
interface Props {
|
||||
children: (collab: CollabAPI) => React.ReactNode;
|
||||
// NOTE not type-safe because the refObject may in fact not be initialized
|
||||
// with ExcalidrawImperativeAPI yet
|
||||
excalidrawRef: React.MutableRefObject<ExcalidrawImperativeAPI>;
|
||||
}
|
||||
|
||||
class CollabWrapper extends PureComponent<Props, CollabState> {
|
||||
portal: Portal;
|
||||
private socketInitializationTimer?: NodeJS.Timeout;
|
||||
private excalidrawRef: Props["excalidrawRef"];
|
||||
excalidrawAppState?: AppState;
|
||||
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
||||
private collaborators = new Map<string, Collaborator>();
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
isCollaborating: false,
|
||||
modalIsShown: false,
|
||||
errorMessage: "",
|
||||
username: importUsernameFromLocalStorage() || "",
|
||||
activeRoomLink: "",
|
||||
};
|
||||
this.portal = new Portal(this);
|
||||
this.excalidrawRef = props.excalidrawRef;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
||||
window.addEventListener(EVENT.UNLOAD, this.onUnload);
|
||||
|
||||
if (
|
||||
process.env.NODE_ENV === ENV.TEST ||
|
||||
process.env.NODE_ENV === ENV.DEVELOPMENT
|
||||
) {
|
||||
window.h = window.h || ({} as Window["h"]);
|
||||
Object.defineProperties(window.h, {
|
||||
collab: {
|
||||
configurable: true,
|
||||
value: this,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
||||
window.removeEventListener(EVENT.UNLOAD, this.onUnload);
|
||||
}
|
||||
|
||||
private onUnload = () => {
|
||||
this.destroySocketClient();
|
||||
};
|
||||
|
||||
private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
|
||||
const syncableElements = getSyncableElements(
|
||||
this.getSceneElementsIncludingDeleted(),
|
||||
);
|
||||
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 = "";
|
||||
}
|
||||
|
||||
if (this.state.isCollaborating || this.portal.roomId) {
|
||||
try {
|
||||
localStorage?.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
|
||||
JSON.stringify({
|
||||
timestamp: Date.now(),
|
||||
room: this.portal.roomId,
|
||||
}),
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
saveCollabRoomToFirebase = async (
|
||||
syncableElements: ExcalidrawElement[] = getSyncableElements(
|
||||
this.excalidrawRef.current!.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
) => {
|
||||
try {
|
||||
await saveToFirebase(this.portal, syncableElements);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
openPortal = async () => {
|
||||
window.history.pushState(
|
||||
{},
|
||||
"Excalidraw",
|
||||
await generateCollaborationLink(),
|
||||
);
|
||||
const elements = this.excalidrawRef.current!.getSceneElements();
|
||||
// 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.
|
||||
this.excalidrawRef.current!.history.clear();
|
||||
this.excalidrawRef.current!.updateScene({
|
||||
elements,
|
||||
commitToHistory: true,
|
||||
});
|
||||
trackEvent(EVENT_SHARE, "session start");
|
||||
return this.initializeSocketClient();
|
||||
};
|
||||
|
||||
closePortal = () => {
|
||||
this.saveCollabRoomToFirebase();
|
||||
window.history.pushState({}, "Excalidraw", window.location.origin);
|
||||
this.destroySocketClient();
|
||||
trackEvent(EVENT_SHARE, "session end");
|
||||
};
|
||||
|
||||
private destroySocketClient = () => {
|
||||
this.collaborators = new Map();
|
||||
this.excalidrawRef.current!.updateScene({
|
||||
collaborators: this.collaborators,
|
||||
});
|
||||
this.setState({
|
||||
isCollaborating: false,
|
||||
activeRoomLink: "",
|
||||
});
|
||||
this.portal.close();
|
||||
};
|
||||
|
||||
private initializeSocketClient = async (): Promise<ImportedDataState | null> => {
|
||||
if (this.portal.socket) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scenePromise = resolvablePromise<ImportedDataState | null>();
|
||||
|
||||
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();
|
||||
scenePromise.resolve(null);
|
||||
}, INITIAL_SCENE_UPDATE_TIMEOUT);
|
||||
|
||||
const { default: socketIOClient }: any = await import(
|
||||
/* webpackChunkName: "socketIoClient" */ "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;
|
||||
const reconciledElements = this.reconcileElements(
|
||||
remoteElements,
|
||||
);
|
||||
this.handleRemoteSceneUpdate(reconciledElements, {
|
||||
init: true,
|
||||
});
|
||||
this.initializeSocket();
|
||||
scenePromise.resolve({ elements: reconciledElements });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SCENE.UPDATE:
|
||||
this.handleRemoteSceneUpdate(
|
||||
this.reconcileElements(decryptedData.payload.elements),
|
||||
);
|
||||
break;
|
||||
case "MOUSE_LOCATION": {
|
||||
const {
|
||||
pointer,
|
||||
button,
|
||||
username,
|
||||
selectedElementIds,
|
||||
} = decryptedData.payload;
|
||||
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
|
||||
decryptedData.payload.socketId ||
|
||||
// @ts-ignore legacy, see #2094 (#2097)
|
||||
decryptedData.payload.socketID;
|
||||
|
||||
const collaborators = new Map(this.collaborators);
|
||||
const user = collaborators.get(socketId) || {}!;
|
||||
user.pointer = pointer;
|
||||
user.button = button;
|
||||
user.selectedElementIds = selectedElementIds;
|
||||
user.username = username;
|
||||
collaborators.set(socketId, user);
|
||||
this.excalidrawRef.current!.updateScene({
|
||||
collaborators,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
this.portal.socket!.on("first-in-room", () => {
|
||||
if (this.portal.socket) {
|
||||
this.portal.socket.off("first-in-room");
|
||||
}
|
||||
this.initializeSocket();
|
||||
scenePromise.resolve(null);
|
||||
});
|
||||
|
||||
this.setState({
|
||||
isCollaborating: true,
|
||||
activeRoomLink: window.location.href,
|
||||
});
|
||||
|
||||
return scenePromise;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
private initializeSocket = () => {
|
||||
this.portal.socketInitialized = true;
|
||||
clearTimeout(this.socketInitializationTimer!);
|
||||
};
|
||||
|
||||
private reconcileElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): ReconciledElements => {
|
||||
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));
|
||||
|
||||
return newElements as ReconciledElements;
|
||||
};
|
||||
|
||||
private handleRemoteSceneUpdate = (
|
||||
elements: ReconciledElements,
|
||||
{
|
||||
init = false,
|
||||
initFromSnapshot = false,
|
||||
}: { init?: boolean; initFromSnapshot?: boolean } = {},
|
||||
) => {
|
||||
if (init || initFromSnapshot) {
|
||||
this.excalidrawRef.current!.setScrollToCenter(elements);
|
||||
}
|
||||
|
||||
this.excalidrawRef.current!.updateScene({
|
||||
elements,
|
||||
commitToHistory: !!init,
|
||||
});
|
||||
|
||||
// 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.excalidrawRef.current!.history.clear();
|
||||
};
|
||||
|
||||
setCollaborators(sockets: string[]) {
|
||||
this.setState((state) => {
|
||||
const collaborators: InstanceType<
|
||||
typeof CollabWrapper
|
||||
>["collaborators"] = new Map();
|
||||
for (const socketId of sockets) {
|
||||
if (this.collaborators.has(socketId)) {
|
||||
collaborators.set(socketId, this.collaborators.get(socketId)!);
|
||||
} else {
|
||||
collaborators.set(socketId, {});
|
||||
}
|
||||
}
|
||||
this.collaborators = collaborators;
|
||||
this.excalidrawRef.current!.updateScene({ collaborators });
|
||||
});
|
||||
}
|
||||
|
||||
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
|
||||
this.lastBroadcastedOrReceivedSceneVersion = version;
|
||||
};
|
||||
|
||||
public getLastBroadcastedOrReceivedSceneVersion = () => {
|
||||
return this.lastBroadcastedOrReceivedSceneVersion;
|
||||
};
|
||||
|
||||
public getSceneElementsIncludingDeleted = () => {
|
||||
return this.excalidrawRef.current!.getSceneElementsIncludingDeleted();
|
||||
};
|
||||
|
||||
onPointerUpdate = (payload: {
|
||||
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
|
||||
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
|
||||
pointersMap: Gesture["pointers"];
|
||||
}) => {
|
||||
payload.pointersMap.size < 2 &&
|
||||
this.portal.socket &&
|
||||
this.portal.broadcastMouseLocation(payload);
|
||||
};
|
||||
|
||||
broadcastElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
state: AppState,
|
||||
) => {
|
||||
this.excalidrawAppState = state;
|
||||
if (
|
||||
getSceneVersion(elements) >
|
||||
this.getLastBroadcastedOrReceivedSceneVersion()
|
||||
) {
|
||||
this.portal.broadcastScene(
|
||||
SCENE.UPDATE,
|
||||
getSyncableElements(elements),
|
||||
false,
|
||||
);
|
||||
this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
|
||||
this.queueBroadcastAllElements();
|
||||
}
|
||||
};
|
||||
|
||||
queueBroadcastAllElements = throttle(() => {
|
||||
this.portal.broadcastScene(
|
||||
SCENE.UPDATE,
|
||||
getSyncableElements(
|
||||
this.excalidrawRef.current!.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
true,
|
||||
);
|
||||
const currentVersion = this.getLastBroadcastedOrReceivedSceneVersion();
|
||||
const newVersion = Math.max(
|
||||
currentVersion,
|
||||
getSceneVersion(this.getSceneElementsIncludingDeleted()),
|
||||
);
|
||||
this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
|
||||
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
||||
|
||||
handleClose = () => {
|
||||
this.setState({ modalIsShown: false });
|
||||
const collabIcon = document.querySelector(".CollabButton") as HTMLElement;
|
||||
collabIcon.focus();
|
||||
};
|
||||
|
||||
onUsernameChange = (username: string) => {
|
||||
this.setState({ username });
|
||||
saveUsernameToLocalStorage(username);
|
||||
};
|
||||
|
||||
onCollabButtonClick = () => {
|
||||
this.setState({
|
||||
modalIsShown: true,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { children } = this.props;
|
||||
const { modalIsShown, username, errorMessage, activeRoomLink } = this.state;
|
||||
|
||||
return (
|
||||
<>
|
||||
{modalIsShown && (
|
||||
<RoomDialog
|
||||
handleClose={this.handleClose}
|
||||
activeRoomLink={activeRoomLink}
|
||||
username={username}
|
||||
onUsernameChange={this.onUsernameChange}
|
||||
onRoomCreate={this.openPortal}
|
||||
onRoomDestroy={this.closePortal}
|
||||
setErrorMessage={(errorMessage) => {
|
||||
this.setState({ errorMessage });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<ErrorDialog
|
||||
message={errorMessage}
|
||||
onClose={() => this.setState({ errorMessage: "" })}
|
||||
/>
|
||||
)}
|
||||
{children({
|
||||
isCollaborating: this.state.isCollaborating,
|
||||
username: this.state.username,
|
||||
onPointerUpdate: this.onPointerUpdate,
|
||||
initializeSocketClient: this.initializeSocketClient,
|
||||
onCollabButtonClick: this.onCollabButtonClick,
|
||||
broadcastElements: this.broadcastElements,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CollabWrapper;
|
218
src/excalidraw-app/collab/Portal.tsx
Normal file
218
src/excalidraw-app/collab/Portal.tsx
Normal file
|
@ -0,0 +1,218 @@
|
|||
import {
|
||||
encryptAESGEM,
|
||||
SocketUpdateData,
|
||||
SocketUpdateDataSource,
|
||||
} from "../data";
|
||||
|
||||
import CollabWrapper from "./CollabWrapper";
|
||||
|
||||
import {
|
||||
getElementMap,
|
||||
getSyncableElements,
|
||||
} from "../../packages/excalidraw/index";
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { BROADCAST, SCENE } from "../app_constants";
|
||||
|
||||
class Portal {
|
||||
app: CollabWrapper;
|
||||
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: CollabWrapper) {
|
||||
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,
|
||||
getSyncableElements(this.app.getSceneElementsIncludingDeleted()),
|
||||
/* 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,
|
||||
syncableElements: ExcalidrawElement[],
|
||||
syncAll: boolean,
|
||||
) => {
|
||||
if (sceneType === SCENE.INIT && !syncAll) {
|
||||
throw new Error("syncAll must be true when sending SCENE.INIT");
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
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.excalidrawAppState?.selectedElementIds || {},
|
||||
username: this.app.state.username,
|
||||
},
|
||||
};
|
||||
return this._broadcastSocketData(
|
||||
data as SocketUpdateData,
|
||||
true, // volatile
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
reconcileElements = (
|
||||
sceneElements: readonly ExcalidrawElement[],
|
||||
): 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
|
||||
return (
|
||||
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.excalidrawAppState?.editingElement?.id ||
|
||||
element.id === this.app.excalidrawAppState?.resizingElement?.id ||
|
||||
element.id === this.app.excalidrawAppState?.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))
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default Portal;
|
63
src/excalidraw-app/collab/RoomDialog.scss
Normal file
63
src/excalidraw-app/collab/RoomDialog.scss
Normal file
|
@ -0,0 +1,63 @@
|
|||
@import "../../css/_variables";
|
||||
|
||||
.excalidraw {
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
136
src/excalidraw-app/collab/RoomDialog.tsx
Normal file
136
src/excalidraw-app/collab/RoomDialog.tsx
Normal file
|
@ -0,0 +1,136 @@
|
|||
import React, { useRef } from "react";
|
||||
import { t } from "../../i18n";
|
||||
import { Dialog } from "../../components/Dialog";
|
||||
import { copyTextToSystemClipboard } from "../../clipboard";
|
||||
import { ToolButton } from "../../components/ToolButton";
|
||||
import { clipboard, start, stop } from "../../components/icons";
|
||||
|
||||
import "./RoomDialog.scss";
|
||||
import { EVENT_SHARE, trackEvent } from "../../analytics";
|
||||
|
||||
const RoomDialog = ({
|
||||
handleClose,
|
||||
activeRoomLink,
|
||||
username,
|
||||
onUsernameChange,
|
||||
onRoomCreate,
|
||||
onRoomDestroy,
|
||||
setErrorMessage,
|
||||
}: {
|
||||
handleClose: () => void;
|
||||
activeRoomLink: string;
|
||||
username: string;
|
||||
onUsernameChange: (username: string) => void;
|
||||
onRoomCreate: () => void;
|
||||
onRoomDestroy: () => 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();
|
||||
}
|
||||
};
|
||||
|
||||
const renderRoomDialog = () => {
|
||||
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 === "Enter" && handleClose()}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
maxWidth={800}
|
||||
onCloseRequest={handleClose}
|
||||
title={t("labels.createRoom")}
|
||||
>
|
||||
{renderRoomDialog()}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomDialog;
|
Loading…
Add table
Add a link
Reference in a new issue