Compare commits
19 Commits
master
...
arnost/soc
Author | SHA1 | Date | |
---|---|---|---|
|
0fe79f8819 | ||
|
772a9999b8 | ||
|
28b0095c8a | ||
|
c4ff0594e3 | ||
|
4608e809b1 | ||
|
db5149ab5d | ||
|
2bdf09153c | ||
|
62df03d78d | ||
|
a1d3350131 | ||
|
a2d371bf1d | ||
|
e340103250 | ||
|
0567af1bcb | ||
|
2ffeff442a | ||
|
ef190ebf30 | ||
|
e1ff9791f2 | ||
|
aa91af8f7d | ||
|
52254bca7c | ||
|
addf9d71fa | ||
|
1badf14a93 |
@ -54,7 +54,7 @@
|
||||
"react-dom": "18.2.0",
|
||||
"roughjs": "4.5.2",
|
||||
"sass": "1.51.0",
|
||||
"socket.io-client": "2.3.1",
|
||||
"socket.io-client": "4.6.1",
|
||||
"tunnel-rat": "0.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -2748,6 +2748,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
toast: {
|
||||
message: string;
|
||||
closable?: boolean;
|
||||
spinner?: boolean;
|
||||
duration?: number;
|
||||
} | null,
|
||||
) => {
|
||||
|
@ -25,6 +25,17 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.Toast__message--spinner {
|
||||
padding: 0 3rem;
|
||||
}
|
||||
|
||||
.Toast__spinner {
|
||||
position: absolute;
|
||||
left: 1.5rem;
|
||||
top: 50%;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
@ -1,5 +1,7 @@
|
||||
import clsx from "clsx";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { CloseIcon } from "./icons";
|
||||
import Spinner from "./Spinner";
|
||||
import "./Toast.scss";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
@ -9,12 +11,14 @@ export const Toast = ({
|
||||
message,
|
||||
onClose,
|
||||
closable = false,
|
||||
spinner = true,
|
||||
// To prevent autoclose, pass duration as Infinity
|
||||
duration = DEFAULT_TOAST_TIMEOUT,
|
||||
}: {
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
closable?: boolean;
|
||||
spinner?: boolean;
|
||||
duration?: number;
|
||||
}) => {
|
||||
const timerRef = useRef<number>(0);
|
||||
@ -44,7 +48,18 @@ export const Toast = ({
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<p className="Toast__message">{message}</p>
|
||||
{spinner && (
|
||||
<div className="Toast__spinner">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
<p
|
||||
className={clsx("Toast__message", {
|
||||
"Toast__message--spinner": spinner,
|
||||
})}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
{closable && (
|
||||
<ToolButton
|
||||
icon={CloseIcon}
|
||||
|
@ -7,6 +7,8 @@ export const SYNC_FULL_SCENE_INTERVAL_MS = 20000;
|
||||
export const SYNC_BROWSER_TABS_TIMEOUT = 50;
|
||||
export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
|
||||
export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
|
||||
export const PAUSE_COLLABORATION_TIMEOUT = 2000;
|
||||
export const RESUME_FALLBACK_TIMEOUT = 5000;
|
||||
|
||||
export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
|
||||
// 1 year (https://stackoverflow.com/a/25201898/927631)
|
||||
|
@ -1,6 +1,6 @@
|
||||
import throttle from "lodash.throttle";
|
||||
import { PureComponent } from "react";
|
||||
import { ExcalidrawImperativeAPI } from "../../types";
|
||||
import { ExcalidrawImperativeAPI, PauseCollaborationState } from "../../types";
|
||||
import { ErrorDialog } from "../../components/ErrorDialog";
|
||||
import { APP_NAME, ENV, EVENT } from "../../constants";
|
||||
import { ImportedDataState } from "../../data/types";
|
||||
@ -16,6 +16,7 @@ import { Collaborator, Gesture } from "../../types";
|
||||
import {
|
||||
preventUnload,
|
||||
resolvablePromise,
|
||||
upsertMap,
|
||||
withBatchedUpdates,
|
||||
} from "../../utils";
|
||||
import {
|
||||
@ -24,12 +25,15 @@ import {
|
||||
FIREBASE_STORAGE_PREFIXES,
|
||||
INITIAL_SCENE_UPDATE_TIMEOUT,
|
||||
LOAD_IMAGES_TIMEOUT,
|
||||
PAUSE_COLLABORATION_TIMEOUT,
|
||||
WS_SCENE_EVENT_TYPES,
|
||||
SYNC_FULL_SCENE_INTERVAL_MS,
|
||||
RESUME_FALLBACK_TIMEOUT,
|
||||
} from "../app_constants";
|
||||
import {
|
||||
generateCollaborationLinkData,
|
||||
getCollaborationLink,
|
||||
getCollaborationLinkData,
|
||||
getCollabServer,
|
||||
getSyncableElements,
|
||||
SocketUpdateDataSource,
|
||||
@ -43,8 +47,8 @@ import {
|
||||
saveToFirebase,
|
||||
} from "../data/firebase";
|
||||
import {
|
||||
importUsernameFromLocalStorage,
|
||||
saveUsernameToLocalStorage,
|
||||
importUsernameAndIdFromLocalStorage,
|
||||
saveUsernameAndIdToLocalStorage,
|
||||
} from "../data/localStorage";
|
||||
import Portal from "./Portal";
|
||||
import RoomDialog from "./RoomDialog";
|
||||
@ -71,16 +75,19 @@ import { resetBrowserStateVersions } from "../data/tabSync";
|
||||
import { LocalData } from "../data/LocalData";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { appJotaiStore } from "../app-jotai";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
||||
export const collabDialogShownAtom = atom(false);
|
||||
export const isCollaboratingAtom = atom(false);
|
||||
export const isOfflineAtom = atom(false);
|
||||
export const isCollaborationPausedAtom = atom(false);
|
||||
|
||||
interface CollabState {
|
||||
errorMessage: string;
|
||||
username: string;
|
||||
activeRoomLink: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
type CollabInstance = InstanceType<typeof Collab>;
|
||||
@ -94,6 +101,7 @@ export interface CollabAPI {
|
||||
syncElements: CollabInstance["syncElements"];
|
||||
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
||||
setUsername: (username: string) => void;
|
||||
isPaused: () => boolean;
|
||||
}
|
||||
|
||||
interface PublicProps {
|
||||
@ -108,6 +116,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
excalidrawAPI: Props["excalidrawAPI"];
|
||||
activeIntervalId: number | null;
|
||||
idleTimeoutId: number | null;
|
||||
pauseTimeoutId: number | null;
|
||||
|
||||
private socketInitializationTimer?: number;
|
||||
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
||||
@ -115,9 +124,13 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
const { username, userId } = importUsernameAndIdFromLocalStorage() || {};
|
||||
|
||||
this.state = {
|
||||
errorMessage: "",
|
||||
username: importUsernameFromLocalStorage() || "",
|
||||
username: username || "",
|
||||
userId: userId || "",
|
||||
activeRoomLink: "",
|
||||
};
|
||||
this.portal = new Portal(this);
|
||||
@ -149,6 +162,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
this.excalidrawAPI = props.excalidrawAPI;
|
||||
this.activeIntervalId = null;
|
||||
this.idleTimeoutId = null;
|
||||
this.pauseTimeoutId = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@ -167,6 +181,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
|
||||
stopCollaboration: this.stopCollaboration,
|
||||
setUsername: this.setUsername,
|
||||
isPaused: this.isPaused,
|
||||
};
|
||||
|
||||
appJotaiStore.set(collabAPIAtom, collabAPI);
|
||||
@ -192,10 +207,13 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
||||
window.removeEventListener(EVENT.UNLOAD, this.onUnload);
|
||||
window.removeEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
|
||||
window.removeEventListener(
|
||||
EVENT.VISIBILITY_CHANGE,
|
||||
this.onVisibilityChange,
|
||||
);
|
||||
// window.removeEventListener(
|
||||
// EVENT.VISIBILITY_CHANGE,
|
||||
// this.onVisibilityChange,
|
||||
// );
|
||||
window.removeEventListener(EVENT.BLUR, this.onVisibilityChange);
|
||||
window.removeEventListener(EVENT.FOCUS, this.onVisibilityChange);
|
||||
|
||||
if (this.activeIntervalId) {
|
||||
window.clearInterval(this.activeIntervalId);
|
||||
this.activeIntervalId = null;
|
||||
@ -204,6 +222,10 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
window.clearTimeout(this.idleTimeoutId);
|
||||
this.idleTimeoutId = null;
|
||||
}
|
||||
if (this.pauseTimeoutId) {
|
||||
window.clearTimeout(this.pauseTimeoutId);
|
||||
this.pauseTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!;
|
||||
@ -307,6 +329,128 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
}
|
||||
};
|
||||
|
||||
fallbackResumeTimeout: null | ReturnType<typeof setTimeout> = null;
|
||||
|
||||
/**
|
||||
* Handles the pause and resume states of a collaboration session.
|
||||
* This function gets triggered when a change in the collaboration pause state is detected.
|
||||
* Based on the state, the function carries out the following actions:
|
||||
* 1. `PAUSED`: Saves the current scene to Firebase, disconnects the socket, and updates the scene to view mode.
|
||||
* 2. `RESUMED`: Connects the socket, shows a toast message, sets a fallback to fetch data from Firebase, and resets the pause timeout if any.
|
||||
* 3. `SYNCED`: Clears the fallback timeout if any, updates the collaboration pause state, and updates the scene to editing mode.
|
||||
*
|
||||
* @param state - The new state of the collaboration session. It is one of the values of `PauseCollaborationState` enum, which includes `PAUSED`, `RESUMED`, and `SYNCED`.
|
||||
*/
|
||||
onPauseCollaborationChange = (state: PauseCollaborationState) => {
|
||||
switch (state) {
|
||||
case PauseCollaborationState.PAUSED: {
|
||||
if (this.portal.socket) {
|
||||
// Save current scene to firebase
|
||||
this.saveCollabRoomToFirebase(
|
||||
getSyncableElements(
|
||||
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
),
|
||||
);
|
||||
|
||||
this.portal.socket.disconnect();
|
||||
this.portal.socketInitialized = false;
|
||||
this.setIsCollaborationPaused(true);
|
||||
|
||||
this.excalidrawAPI.updateScene({
|
||||
appState: { viewModeEnabled: true },
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case PauseCollaborationState.RESUMED: {
|
||||
if (this.portal.socket && this.isPaused()) {
|
||||
this.portal.socket.connect();
|
||||
this.portal.socket.emit(WS_SCENE_EVENT_TYPES.INIT);
|
||||
|
||||
console.log("setting toast");
|
||||
this.excalidrawAPI.setToast({
|
||||
message: t("toast.reconnectRoomServer"),
|
||||
duration: Infinity,
|
||||
spinner: true,
|
||||
closable: false,
|
||||
});
|
||||
|
||||
// Fallback to fetch data from firebase when reconnecting to scene without collaborators
|
||||
const fallbackResumeHandler = async () => {
|
||||
const roomLinkData = getCollaborationLinkData(
|
||||
this.state.activeRoomLink,
|
||||
);
|
||||
if (!roomLinkData) {
|
||||
return;
|
||||
}
|
||||
const elements = await loadFromFirebase(
|
||||
roomLinkData.roomId,
|
||||
roomLinkData.roomKey,
|
||||
this.portal.socket,
|
||||
);
|
||||
if (elements) {
|
||||
this.setLastBroadcastedOrReceivedSceneVersion(
|
||||
getSceneVersion(elements),
|
||||
);
|
||||
|
||||
this.excalidrawAPI.updateScene({
|
||||
elements,
|
||||
});
|
||||
}
|
||||
this.onPauseCollaborationChange(PauseCollaborationState.SYNCED);
|
||||
};
|
||||
|
||||
// Set timeout to fallback to fetch data from firebase
|
||||
this.fallbackResumeTimeout = setTimeout(
|
||||
fallbackResumeHandler,
|
||||
RESUME_FALLBACK_TIMEOUT,
|
||||
);
|
||||
|
||||
// When no users are in the room, we fallback to fetch data from firebase immediately and clear fallback timeout
|
||||
this.portal.socket.on("first-in-room", () => {
|
||||
if (this.portal.socket) {
|
||||
this.portal.socket.off("first-in-room");
|
||||
// Recall init event to initialize collab with other users (fixes https://github.com/excalidraw/excalidraw/pull/6638#issuecomment-1600799080)
|
||||
this.portal.socket.emit(WS_SCENE_EVENT_TYPES.INIT);
|
||||
}
|
||||
|
||||
fallbackResumeHandler();
|
||||
});
|
||||
}
|
||||
|
||||
// Clear pause timeout if exists
|
||||
if (this.pauseTimeoutId) {
|
||||
clearTimeout(this.pauseTimeoutId);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case PauseCollaborationState.SYNCED: {
|
||||
if (this.fallbackResumeTimeout) {
|
||||
clearTimeout(this.fallbackResumeTimeout);
|
||||
this.fallbackResumeTimeout = null;
|
||||
}
|
||||
|
||||
if (this.isPaused()) {
|
||||
this.setIsCollaborationPaused(false);
|
||||
|
||||
this.excalidrawAPI.updateScene({
|
||||
appState: { viewModeEnabled: false },
|
||||
});
|
||||
console.log("resetting toast");
|
||||
this.excalidrawAPI.setToast(null);
|
||||
this.excalidrawAPI.scrollToContent();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
isPaused = () => appJotaiStore.get(isCollaborationPausedAtom)!;
|
||||
|
||||
setIsCollaborationPaused = (isPaused: boolean) => {
|
||||
appJotaiStore.set(isCollaborationPausedAtom, isPaused);
|
||||
};
|
||||
|
||||
private destroySocketClient = (opts?: { isUnload: boolean }) => {
|
||||
this.lastBroadcastedOrReceivedSceneVersion = -1;
|
||||
this.portal.close();
|
||||
@ -385,6 +529,11 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.state.userId) {
|
||||
const userId = nanoid();
|
||||
this.onUserIdChange(userId);
|
||||
}
|
||||
|
||||
if (this.portal.socket) {
|
||||
return null;
|
||||
}
|
||||
@ -499,6 +648,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
elements: reconciledElements,
|
||||
scrollToContent: true,
|
||||
});
|
||||
this.onPauseCollaborationChange(PauseCollaborationState.SYNCED);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@ -508,33 +658,45 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
);
|
||||
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);
|
||||
const {
|
||||
pointer,
|
||||
button,
|
||||
username,
|
||||
selectedElementIds,
|
||||
userId,
|
||||
socketId,
|
||||
} = decryptedData.payload;
|
||||
const collaborators = upsertMap(
|
||||
userId,
|
||||
{
|
||||
username,
|
||||
pointer,
|
||||
button,
|
||||
selectedElementIds,
|
||||
socketId,
|
||||
},
|
||||
this.collaborators,
|
||||
);
|
||||
this.excalidrawAPI.updateScene({
|
||||
collaborators,
|
||||
collaborators: new Map(collaborators),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "IDLE_STATUS": {
|
||||
const { userState, socketId, username } = decryptedData.payload;
|
||||
const collaborators = new Map(this.collaborators);
|
||||
const user = collaborators.get(socketId) || {}!;
|
||||
user.userState = userState;
|
||||
user.username = username;
|
||||
const { userState, username, userId, socketId } =
|
||||
decryptedData.payload;
|
||||
const collaborators = upsertMap(
|
||||
userId,
|
||||
{
|
||||
username,
|
||||
userState,
|
||||
userId,
|
||||
socketId,
|
||||
},
|
||||
this.collaborators,
|
||||
);
|
||||
this.excalidrawAPI.updateScene({
|
||||
collaborators,
|
||||
collaborators: new Map(collaborators),
|
||||
});
|
||||
break;
|
||||
}
|
||||
@ -543,6 +705,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
);
|
||||
|
||||
this.portal.socket.on("first-in-room", async () => {
|
||||
console.log("first in room");
|
||||
if (this.portal.socket) {
|
||||
this.portal.socket.off("first-in-room");
|
||||
}
|
||||
@ -684,7 +847,9 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
};
|
||||
|
||||
private onVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
// if (document.hidden) {
|
||||
console.log("VIS CHANGE");
|
||||
if (!document.hasFocus()) {
|
||||
if (this.idleTimeoutId) {
|
||||
window.clearTimeout(this.idleTimeoutId);
|
||||
this.idleTimeoutId = null;
|
||||
@ -693,6 +858,10 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
window.clearInterval(this.activeIntervalId);
|
||||
this.activeIntervalId = null;
|
||||
}
|
||||
this.pauseTimeoutId = window.setTimeout(
|
||||
() => this.onPauseCollaborationChange(PauseCollaborationState.PAUSED),
|
||||
PAUSE_COLLABORATION_TIMEOUT,
|
||||
);
|
||||
this.onIdleStateChange(UserIdleState.AWAY);
|
||||
} else {
|
||||
this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
|
||||
@ -701,6 +870,11 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
ACTIVE_THRESHOLD,
|
||||
);
|
||||
this.onIdleStateChange(UserIdleState.ACTIVE);
|
||||
if (this.pauseTimeoutId) {
|
||||
window.clearTimeout(this.pauseTimeoutId);
|
||||
this.onPauseCollaborationChange(PauseCollaborationState.RESUMED);
|
||||
this.pauseTimeoutId = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -717,22 +891,19 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
};
|
||||
|
||||
private initializeIdleDetector = () => {
|
||||
document.addEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
|
||||
document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange);
|
||||
// document.addEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
|
||||
// document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange);
|
||||
window.addEventListener(EVENT.BLUR, this.onVisibilityChange);
|
||||
window.addEventListener(EVENT.FOCUS, this.onVisibilityChange);
|
||||
};
|
||||
|
||||
setCollaborators(sockets: string[]) {
|
||||
const collaborators: InstanceType<typeof Collab>["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.forEach((value, key) => {
|
||||
if (value.socketId && !sockets.includes(value.socketId)) {
|
||||
this.collaborators.delete(key);
|
||||
}
|
||||
}
|
||||
this.collaborators = collaborators;
|
||||
this.excalidrawAPI.updateScene({ collaborators });
|
||||
});
|
||||
this.excalidrawAPI.updateScene({ collaborators: this.collaborators });
|
||||
}
|
||||
|
||||
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
|
||||
@ -818,7 +989,12 @@ class Collab extends PureComponent<Props, CollabState> {
|
||||
|
||||
onUsernameChange = (username: string) => {
|
||||
this.setUsername(username);
|
||||
saveUsernameToLocalStorage(username);
|
||||
saveUsernameAndIdToLocalStorage(username, this.state.userId);
|
||||
};
|
||||
|
||||
onUserIdChange = (userId: string) => {
|
||||
this.setState({ userId });
|
||||
saveUsernameAndIdToLocalStorage(this.state.username, userId);
|
||||
};
|
||||
|
||||
render() {
|
||||
|
@ -34,26 +34,12 @@ class Portal {
|
||||
|
||||
open(socket: SocketIOClient.Socket, id: string, key: string) {
|
||||
this.socket = socket;
|
||||
// @ts-ignore
|
||||
window.socket = socket;
|
||||
this.roomId = id;
|
||||
this.roomKey = key;
|
||||
|
||||
// Initialize socket listeners
|
||||
this.socket.on("init-room", () => {
|
||||
if (this.socket) {
|
||||
this.socket.emit("join-room", this.roomId);
|
||||
trackEvent("share", "room joined");
|
||||
}
|
||||
});
|
||||
this.socket.on("new-user", async (_socketId: string) => {
|
||||
this.broadcastScene(
|
||||
WS_SCENE_EVENT_TYPES.INIT,
|
||||
this.collab.getSceneElementsIncludingDeleted(),
|
||||
/* syncAll */ true,
|
||||
);
|
||||
});
|
||||
this.socket.on("room-user-change", (clients: string[]) => {
|
||||
this.collab.setCollaborators(clients);
|
||||
});
|
||||
this.initializeSocketListeners();
|
||||
|
||||
return socket;
|
||||
}
|
||||
@ -71,6 +57,31 @@ class Portal {
|
||||
this.broadcastedElementVersions = new Map();
|
||||
}
|
||||
|
||||
initializeSocketListeners() {
|
||||
if (!this.socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize socket listeners
|
||||
this.socket.on("init-room", () => {
|
||||
console.log("join room");
|
||||
if (this.socket) {
|
||||
this.socket.emit("join-room", this.roomId);
|
||||
trackEvent("share", "room joined");
|
||||
}
|
||||
});
|
||||
this.socket.on("new-user", async (_socketId: string) => {
|
||||
this.broadcastScene(
|
||||
WS_SCENE_EVENT_TYPES.INIT,
|
||||
this.collab.getSceneElementsIncludingDeleted(),
|
||||
/* syncAll */ true,
|
||||
);
|
||||
});
|
||||
this.socket.on("room-user-change", (clients: string[]) => {
|
||||
this.collab.setCollaborators(clients);
|
||||
});
|
||||
}
|
||||
|
||||
isOpen() {
|
||||
return !!(
|
||||
this.socketInitialized &&
|
||||
@ -181,13 +192,14 @@ class Portal {
|
||||
};
|
||||
|
||||
broadcastIdleChange = (userState: UserIdleState) => {
|
||||
if (this.socket?.id) {
|
||||
if (this.socket) {
|
||||
const data: SocketUpdateDataSource["IDLE_STATUS"] = {
|
||||
type: "IDLE_STATUS",
|
||||
payload: {
|
||||
socketId: this.socket.id,
|
||||
userState,
|
||||
username: this.collab.state.username,
|
||||
userId: this.collab.state.userId,
|
||||
socketId: this.socket.id,
|
||||
},
|
||||
};
|
||||
return this._broadcastSocketData(
|
||||
@ -201,16 +213,17 @@ class Portal {
|
||||
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
|
||||
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
|
||||
}) => {
|
||||
if (this.socket?.id) {
|
||||
if (this.socket) {
|
||||
const data: SocketUpdateDataSource["MOUSE_LOCATION"] = {
|
||||
type: "MOUSE_LOCATION",
|
||||
payload: {
|
||||
socketId: this.socket.id,
|
||||
pointer: payload.pointer,
|
||||
button: payload.button || "up",
|
||||
selectedElementIds:
|
||||
this.collab.excalidrawAPI.getAppState().selectedElementIds,
|
||||
username: this.collab.state.username,
|
||||
userId: this.collab.state.userId,
|
||||
socketId: this.socket.id,
|
||||
},
|
||||
};
|
||||
return this._broadcastSocketData(
|
||||
|
@ -106,19 +106,21 @@ export type SocketUpdateDataSource = {
|
||||
MOUSE_LOCATION: {
|
||||
type: "MOUSE_LOCATION";
|
||||
payload: {
|
||||
socketId: string;
|
||||
pointer: { x: number; y: number };
|
||||
button: "down" | "up";
|
||||
selectedElementIds: AppState["selectedElementIds"];
|
||||
username: string;
|
||||
userId: string;
|
||||
socketId: string;
|
||||
};
|
||||
};
|
||||
IDLE_STATUS: {
|
||||
type: "IDLE_STATUS";
|
||||
payload: {
|
||||
socketId: string;
|
||||
userState: UserIdleState;
|
||||
username: string;
|
||||
userId: string;
|
||||
socketId: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
@ -8,11 +8,14 @@ import { clearElementsForLocalStorage } from "../../element";
|
||||
import { STORAGE_KEYS } from "../app_constants";
|
||||
import { ImportedDataState } from "../../data/types";
|
||||
|
||||
export const saveUsernameToLocalStorage = (username: string) => {
|
||||
export const saveUsernameAndIdToLocalStorage = (
|
||||
username: string,
|
||||
userId: string,
|
||||
) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_COLLAB,
|
||||
JSON.stringify({ username }),
|
||||
JSON.stringify({ username, userId }),
|
||||
);
|
||||
} catch (error: any) {
|
||||
// Unable to access window.localStorage
|
||||
@ -20,11 +23,14 @@ export const saveUsernameToLocalStorage = (username: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const importUsernameFromLocalStorage = (): string | null => {
|
||||
export const importUsernameAndIdFromLocalStorage = (): {
|
||||
username: string;
|
||||
userId: string;
|
||||
} | null => {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
|
||||
if (data) {
|
||||
return JSON.parse(data).username;
|
||||
return JSON.parse(data);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Unable to access localStorage
|
||||
|
@ -65,7 +65,7 @@ import {
|
||||
import {
|
||||
getLibraryItemsFromStorage,
|
||||
importFromLocalStorage,
|
||||
importUsernameFromLocalStorage,
|
||||
importUsernameAndIdFromLocalStorage,
|
||||
} from "./data/localStorage";
|
||||
import CustomStats from "./CustomStats";
|
||||
import { restore, restoreAppState, RestoredDataState } from "../data/restore";
|
||||
@ -425,7 +425,8 @@ const ExcalidrawWrapper = () => {
|
||||
// don't sync if local state is newer or identical to browser state
|
||||
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
|
||||
const localDataState = importFromLocalStorage();
|
||||
const username = importUsernameFromLocalStorage();
|
||||
const username =
|
||||
importUsernameAndIdFromLocalStorage()?.username ?? "";
|
||||
let langCode = languageDetector.detect() || defaultLang.code;
|
||||
if (Array.isArray(langCode)) {
|
||||
langCode = langCode[0];
|
||||
|
@ -425,7 +425,8 @@
|
||||
"selection": "selection",
|
||||
"pasteAsSingleElement": "Use {{shortcut}} to paste as a single element,\nor paste into an existing text editor",
|
||||
"unableToEmbed": "Embedding this url is currently not allowed. Raise an issue on GitHub to request the url whitelisted",
|
||||
"unrecognizedLinkFormat": "The link you embedded does not match the expected format. Please try to paste the 'embed' string provided by the source site"
|
||||
"unrecognizedLinkFormat": "The link you embedded does not match the expected format. Please try to paste the 'embed' string provided by the source site",
|
||||
"reconnectRoomServer": "Reconnecting to server"
|
||||
},
|
||||
"colors": {
|
||||
"transparent": "Transparent",
|
||||
|
@ -707,8 +707,8 @@ export const _renderScene = ({
|
||||
if (renderConfig.remoteSelectedElementIds[element.id]) {
|
||||
selectionColors.push(
|
||||
...renderConfig.remoteSelectedElementIds[element.id].map(
|
||||
(socketId) => {
|
||||
const background = getClientColor(socketId);
|
||||
(userId) => {
|
||||
const background = getClientColor(userId);
|
||||
return background;
|
||||
},
|
||||
),
|
||||
|
@ -56,6 +56,7 @@ export type Collaborator = {
|
||||
avatarUrl?: string;
|
||||
// user id. If supplied, we'll filter out duplicates when rendering user avatars.
|
||||
id?: string;
|
||||
socketId?: string;
|
||||
};
|
||||
|
||||
export type DataURL = string & { _brand: "DataURL" };
|
||||
@ -401,6 +402,12 @@ export enum UserIdleState {
|
||||
IDLE = "idle",
|
||||
}
|
||||
|
||||
export enum PauseCollaborationState {
|
||||
PAUSED = "paused",
|
||||
RESUMED = "resumed",
|
||||
SYNCED = "synced",
|
||||
}
|
||||
|
||||
export type ExportOpts = {
|
||||
saveFileToDisk?: boolean;
|
||||
onExportToBackend?: (
|
||||
|
11
src/utils.ts
11
src/utils.ts
@ -914,3 +914,14 @@ export const isOnlyExportingSingleFrame = (
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const upsertMap = <T>(key: T, value: object, map: Map<T, object>) => {
|
||||
if (!map.has(key)) {
|
||||
map.set(key, value);
|
||||
} else {
|
||||
const old = map.get(key);
|
||||
map.set(key, { ...old, ...value });
|
||||
}
|
||||
|
||||
return map;
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user