Kaynağa Gözat

feat: follow mode (#6848)

Co-authored-by: dwelle <[email protected]>
Barnabás Molnár 1 yıl önce
ebeveyn
işleme
aad8ab0123
28 değiştirilmiş dosya ile 1038 ekleme ve 137 silme
  1. 5 2
      excalidraw-app/app_constants.ts
  2. 128 20
      excalidraw-app/collab/Collab.tsx
  3. 40 9
      excalidraw-app/collab/Portal.tsx
  4. 8 0
      excalidraw-app/data/index.ts
  5. 47 10
      packages/excalidraw/actions/actionCanvas.tsx
  6. 42 14
      packages/excalidraw/actions/actionNavigate.tsx
  7. 4 0
      packages/excalidraw/appState.ts
  8. 60 1
      packages/excalidraw/components/App.tsx
  9. 1 29
      packages/excalidraw/components/Avatar.scss
  10. 14 2
      packages/excalidraw/components/Avatar.tsx
  11. 59 0
      packages/excalidraw/components/FollowMode/FollowMode.scss
  12. 43 0
      packages/excalidraw/components/FollowMode/FollowMode.tsx
  13. 4 0
      packages/excalidraw/components/Sidebar/SidebarTrigger.scss
  14. 1 1
      packages/excalidraw/components/Sidebar/SidebarTrigger.tsx
  15. 96 4
      packages/excalidraw/components/UserList.scss
  16. 221 38
      packages/excalidraw/components/UserList.tsx
  17. 9 0
      packages/excalidraw/components/icons.tsx
  18. 2 0
      packages/excalidraw/css/theme.scss
  19. 38 0
      packages/excalidraw/css/variables.module.scss
  20. 1 0
      packages/excalidraw/index.tsx
  21. 9 0
      packages/excalidraw/locales/en.json
  22. 34 0
      packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
  23. 104 0
      packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
  24. 3 0
      packages/excalidraw/tests/packages/__snapshots__/utils.test.ts.snap
  25. 27 4
      packages/excalidraw/types.ts
  26. 34 3
      packages/excalidraw/utils.ts
  27. 2 0
      packages/utils/__snapshots__/export.test.ts.snap
  28. 2 0
      packages/utils/__snapshots__/utils.test.ts.snap

+ 5 - 2
excalidraw-app/app_constants.ts

@@ -15,11 +15,14 @@ export const FILE_CACHE_MAX_AGE_SEC = 31536000;
 export const WS_EVENTS = {
   SERVER_VOLATILE: "server-volatile-broadcast",
   SERVER: "server-broadcast",
-};
+  USER_FOLLOW_CHANGE: "user-follow",
+  USER_FOLLOW_ROOM_CHANGE: "user-follow-room-change",
+} as const;
 
-export enum WS_SCENE_EVENT_TYPES {
+export enum WS_SUBTYPES {
   INIT = "SCENE_INIT",
   UPDATE = "SCENE_UPDATE",
+  USER_VIEWPORT_BOUNDS = "USER_VIEWPORT_BOUNDS",
 }
 
 export const FIREBASE_STORAGE_PREFIXES = {

+ 128 - 20
excalidraw-app/collab/Collab.tsx

@@ -1,6 +1,9 @@
 import throttle from "lodash.throttle";
 import { PureComponent } from "react";
-import { ExcalidrawImperativeAPI } from "../../packages/excalidraw/types";
+import {
+  ExcalidrawImperativeAPI,
+  SocketId,
+} from "../../packages/excalidraw/types";
 import { ErrorDialog } from "../../packages/excalidraw/components/ErrorDialog";
 import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants";
 import { ImportedDataState } from "../../packages/excalidraw/data/types";
@@ -11,11 +14,14 @@ import {
 import {
   getSceneVersion,
   restoreElements,
+  zoomToFitBounds,
 } from "../../packages/excalidraw/index";
 import { Collaborator, Gesture } from "../../packages/excalidraw/types";
 import {
   preventUnload,
   resolvablePromise,
+  throttleRAF,
+  viewportCoordsToSceneCoords,
   withBatchedUpdates,
 } from "../../packages/excalidraw/utils";
 import {
@@ -24,8 +30,9 @@ import {
   FIREBASE_STORAGE_PREFIXES,
   INITIAL_SCENE_UPDATE_TIMEOUT,
   LOAD_IMAGES_TIMEOUT,
-  WS_SCENE_EVENT_TYPES,
+  WS_SUBTYPES,
   SYNC_FULL_SCENE_INTERVAL_MS,
+  WS_EVENTS,
 } from "../app_constants";
 import {
   generateCollaborationLinkData,
@@ -74,6 +81,7 @@ import { resetBrowserStateVersions } from "../data/tabSync";
 import { LocalData } from "../data/LocalData";
 import { atom, useAtom } from "jotai";
 import { appJotaiStore } from "../app-jotai";
+import { Mutable } from "../../packages/excalidraw/utility-types";
 
 export const collabAPIAtom = atom<CollabAPI | null>(null);
 export const collabDialogShownAtom = atom(false);
@@ -154,12 +162,28 @@ class Collab extends PureComponent<Props, CollabState> {
     this.idleTimeoutId = null;
   }
 
+  private onUmmount: (() => void) | null = null;
+
   componentDidMount() {
     window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
     window.addEventListener("online", this.onOfflineStatusToggle);
     window.addEventListener("offline", this.onOfflineStatusToggle);
     window.addEventListener(EVENT.UNLOAD, this.onUnload);
 
+    const unsubOnUserFollow = this.excalidrawAPI.onUserFollow((payload) => {
+      this.portal.socket && this.portal.broadcastUserFollowed(payload);
+    });
+    const throttledRelayUserViewportBounds = throttleRAF(
+      this.relayUserViewportBounds,
+    );
+    const unsubOnScrollChange = this.excalidrawAPI.onScrollChange(() =>
+      throttledRelayUserViewportBounds(),
+    );
+    this.onUmmount = () => {
+      unsubOnUserFollow();
+      unsubOnScrollChange();
+    };
+
     this.onOfflineStatusToggle();
 
     const collabAPI: CollabAPI = {
@@ -207,6 +231,7 @@ class Collab extends PureComponent<Props, CollabState> {
       window.clearTimeout(this.idleTimeoutId);
       this.idleTimeoutId = null;
     }
+    this.onUmmount?.();
   }
 
   isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!;
@@ -489,7 +514,7 @@ class Collab extends PureComponent<Props, CollabState> {
         switch (decryptedData.type) {
           case "INVALID_RESPONSE":
             return;
-          case WS_SCENE_EVENT_TYPES.INIT: {
+          case WS_SUBTYPES.INIT: {
             if (!this.portal.socketInitialized) {
               this.initializeRoom({ fetchScene: false });
               const remoteElements = decryptedData.payload.elements;
@@ -505,7 +530,7 @@ class Collab extends PureComponent<Props, CollabState> {
             }
             break;
           }
-          case WS_SCENE_EVENT_TYPES.UPDATE:
+          case WS_SUBTYPES.UPDATE:
             this.handleRemoteSceneUpdate(
               this.reconcileElements(decryptedData.payload.elements),
             );
@@ -513,31 +538,61 @@ class Collab extends PureComponent<Props, CollabState> {
           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.updateCollaborator(socketId, {
+              pointer,
+              button,
+              selectedElementIds,
+              username,
+            });
+
+            break;
+          }
+
+          case WS_SUBTYPES.USER_VIEWPORT_BOUNDS: {
+            const { bounds, socketId } = decryptedData.payload;
+
+            const appState = this.excalidrawAPI.getAppState();
+
+            // we're not following the user
+            // (shouldn't happen, but could be late message or bug upstream)
+            if (appState.userToFollow?.socketId !== socketId) {
+              console.warn(
+                `receiving remote client's (from ${socketId}) viewport bounds even though we're not subscribed to it!`,
+              );
+              return;
+            }
+
+            // cross-follow case, ignore updates in this case
+            if (
+              appState.userToFollow &&
+              appState.followedBy.has(appState.userToFollow.socketId)
+            ) {
+              return;
+            }
+
             this.excalidrawAPI.updateScene({
-              collaborators,
+              appState: zoomToFitBounds({
+                appState,
+                bounds,
+                fitToViewport: true,
+                viewportZoomFactor: 1,
+              }).appState,
             });
+
             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;
-            this.excalidrawAPI.updateScene({
-              collaborators,
+            this.updateCollaborator(socketId, {
+              userState,
+              username,
             });
             break;
           }
@@ -556,6 +611,17 @@ class Collab extends PureComponent<Props, CollabState> {
       scenePromise.resolve(sceneData);
     });
 
+    this.portal.socket.on(
+      WS_EVENTS.USER_FOLLOW_ROOM_CHANGE,
+      (followedBy: string[]) => {
+        this.excalidrawAPI.updateScene({
+          appState: { followedBy: new Set(followedBy) },
+        });
+
+        this.relayUserViewportBounds({ shouldPerform: true });
+      },
+    );
+
     this.initializeIdleDetector();
 
     this.setState({
@@ -738,6 +804,24 @@ class Collab extends PureComponent<Props, CollabState> {
     this.excalidrawAPI.updateScene({ collaborators });
   }
 
+  private updateCollaborator = (
+    socketId: SocketId,
+    updates: Partial<Collaborator>,
+  ) => {
+    const collaborators = new Map(this.collaborators);
+    const user: Mutable<Collaborator> = Object.assign(
+      {},
+      collaborators.get(socketId),
+      updates,
+    );
+    collaborators.set(socketId, user);
+    this.collaborators = collaborators;
+
+    this.excalidrawAPI.updateScene({
+      collaborators,
+    });
+  };
+
   public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
     this.lastBroadcastedOrReceivedSceneVersion = version;
   };
@@ -763,6 +847,30 @@ class Collab extends PureComponent<Props, CollabState> {
     CURSOR_SYNC_TIMEOUT,
   );
 
+  relayUserViewportBounds = (props?: { shouldPerform: boolean }) => {
+    const appState = this.excalidrawAPI.getAppState();
+
+    if (
+      this.portal.socket &&
+      (appState.followedBy.size > 0 || props?.shouldPerform)
+    ) {
+      const { x: x1, y: y1 } = viewportCoordsToSceneCoords(
+        { clientX: 0, clientY: 0 },
+        appState,
+      );
+
+      const { x: x2, y: y2 } = viewportCoordsToSceneCoords(
+        { clientX: appState.width, clientY: appState.height },
+        appState,
+      );
+
+      this.portal.broadcastUserViewportBounds(
+        { bounds: [x1, y1, x2, y2] },
+        `follow_${this.portal.socket.id}`,
+      );
+    }
+  };
+
   onIdleStateChange = (userState: UserIdleState) => {
     this.portal.broadcastIdleChange(userState);
   };
@@ -772,7 +880,7 @@ class Collab extends PureComponent<Props, CollabState> {
       getSceneVersion(elements) >
       this.getLastBroadcastedOrReceivedSceneVersion()
     ) {
-      this.portal.broadcastScene(WS_SCENE_EVENT_TYPES.UPDATE, elements, false);
+      this.portal.broadcastScene(WS_SUBTYPES.UPDATE, elements, false);
       this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
       this.queueBroadcastAllElements();
     }
@@ -785,7 +893,7 @@ class Collab extends PureComponent<Props, CollabState> {
 
   queueBroadcastAllElements = throttle(() => {
     this.portal.broadcastScene(
-      WS_SCENE_EVENT_TYPES.UPDATE,
+      WS_SUBTYPES.UPDATE,
       this.excalidrawAPI.getSceneElementsIncludingDeleted(),
       true,
     );

+ 40 - 9
excalidraw-app/collab/Portal.tsx

@@ -7,12 +7,11 @@ import {
 import { TCollabClass } from "./Collab";
 
 import { ExcalidrawElement } from "../../packages/excalidraw/element/types";
+import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
 import {
-  WS_EVENTS,
-  FILE_UPLOAD_TIMEOUT,
-  WS_SCENE_EVENT_TYPES,
-} from "../app_constants";
-import { UserIdleState } from "../../packages/excalidraw/types";
+  OnUserFollowedPayload,
+  UserIdleState,
+} from "../../packages/excalidraw/types";
 import { trackEvent } from "../../packages/excalidraw/analytics";
 import throttle from "lodash.throttle";
 import { newElementWith } from "../../packages/excalidraw/element/mutateElement";
@@ -46,7 +45,7 @@ class Portal {
     });
     this.socket.on("new-user", async (_socketId: string) => {
       this.broadcastScene(
-        WS_SCENE_EVENT_TYPES.INIT,
+        WS_SUBTYPES.INIT,
         this.collab.getSceneElementsIncludingDeleted(),
         /* syncAll */ true,
       );
@@ -83,6 +82,7 @@ class Portal {
   async _broadcastSocketData(
     data: SocketUpdateData,
     volatile: boolean = false,
+    roomId?: string,
   ) {
     if (this.isOpen()) {
       const json = JSON.stringify(data);
@@ -91,7 +91,7 @@ class Portal {
 
       this.socket?.emit(
         volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER,
-        this.roomId,
+        roomId ?? this.roomId,
         encryptedBuffer,
         iv,
       );
@@ -130,11 +130,11 @@ class Portal {
   }, FILE_UPLOAD_TIMEOUT);
 
   broadcastScene = async (
-    updateType: WS_SCENE_EVENT_TYPES.INIT | WS_SCENE_EVENT_TYPES.UPDATE,
+    updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE,
     allElements: readonly ExcalidrawElement[],
     syncAll: boolean,
   ) => {
-    if (updateType === WS_SCENE_EVENT_TYPES.INIT && !syncAll) {
+    if (updateType === WS_SUBTYPES.INIT && !syncAll) {
       throw new Error("syncAll must be true when sending SCENE.INIT");
     }
 
@@ -213,12 +213,43 @@ class Portal {
           username: this.collab.state.username,
         },
       };
+
+      return this._broadcastSocketData(
+        data as SocketUpdateData,
+        true, // volatile
+      );
+    }
+  };
+
+  broadcastUserViewportBounds = (
+    payload: {
+      bounds: [number, number, number, number];
+    },
+    roomId: string,
+  ) => {
+    if (this.socket?.id) {
+      const data: SocketUpdateDataSource["USER_VIEWPORT_BOUNDS"] = {
+        type: WS_SUBTYPES.USER_VIEWPORT_BOUNDS,
+        payload: {
+          socketId: this.socket.id,
+          username: this.collab.state.username,
+          bounds: payload.bounds,
+        },
+      };
+
       return this._broadcastSocketData(
         data as SocketUpdateData,
         true, // volatile
+        roomId,
       );
     }
   };
+
+  broadcastUserFollowed = (payload: OnUserFollowedPayload) => {
+    if (this.socket?.id) {
+      this.socket.emit(WS_EVENTS.USER_FOLLOW_CHANGE, payload);
+    }
+  };
 }
 
 export default Portal;

+ 8 - 0
excalidraw-app/data/index.ts

@@ -119,6 +119,14 @@ export type SocketUpdateDataSource = {
       username: string;
     };
   };
+  USER_VIEWPORT_BOUNDS: {
+    type: "USER_VIEWPORT_BOUNDS";
+    payload: {
+      socketId: string;
+      username: string;
+      bounds: [number, number, number, number];
+    };
+  };
   IDLE_STATUS: {
     type: "IDLE_STATUS";
     payload: {

+ 47 - 10
packages/excalidraw/actions/actionCanvas.tsx

@@ -109,6 +109,7 @@ export const actionZoomIn = register({
           },
           appState,
         ),
+        userToFollow: null,
       },
       commitToHistory: false,
     };
@@ -146,6 +147,7 @@ export const actionZoomOut = register({
           },
           appState,
         ),
+        userToFollow: null,
       },
       commitToHistory: false,
     };
@@ -183,6 +185,7 @@ export const actionResetZoom = register({
           },
           appState,
         ),
+        userToFollow: null,
       },
       commitToHistory: false,
     };
@@ -226,22 +229,20 @@ const zoomValueToFitBoundsOnViewport = (
   return clampedZoomValueToFitElements as NormalizedZoomValue;
 };
 
-export const zoomToFit = ({
-  targetElements,
+export const zoomToFitBounds = ({
+  bounds,
   appState,
   fitToViewport = false,
   viewportZoomFactor = 0.7,
 }: {
-  targetElements: readonly ExcalidrawElement[];
+  bounds: readonly [number, number, number, number];
   appState: Readonly<AppState>;
   /** whether to fit content to viewport (beyond >100%) */
   fitToViewport: boolean;
   /** zoom content to cover X of the viewport, when fitToViewport=true */
   viewportZoomFactor?: number;
 }) => {
-  const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
-
-  const [x1, y1, x2, y2] = commonBounds;
+  const [x1, y1, x2, y2] = bounds;
   const centerX = (x1 + x2) / 2;
   const centerY = (y1 + y2) / 2;
 
@@ -282,7 +283,7 @@ export const zoomToFit = ({
     scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX;
     scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
   } else {
-    newZoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
+    newZoomValue = zoomValueToFitBoundsOnViewport(bounds, {
       width: appState.width,
       height: appState.height,
     });
@@ -311,6 +312,29 @@ export const zoomToFit = ({
   };
 };
 
+export const zoomToFit = ({
+  targetElements,
+  appState,
+  fitToViewport,
+  viewportZoomFactor,
+}: {
+  targetElements: readonly ExcalidrawElement[];
+  appState: Readonly<AppState>;
+  /** whether to fit content to viewport (beyond >100%) */
+  fitToViewport: boolean;
+  /** zoom content to cover X of the viewport, when fitToViewport=true */
+  viewportZoomFactor?: number;
+}) => {
+  const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
+
+  return zoomToFitBounds({
+    bounds: commonBounds,
+    appState,
+    fitToViewport,
+    viewportZoomFactor,
+  });
+};
+
 // Note, this action differs from actionZoomToFitSelection in that it doesn't
 // zoom beyond 100%. In other words, if the content is smaller than viewport
 // size, it won't be zoomed in.
@@ -321,7 +345,10 @@ export const actionZoomToFitSelectionInViewport = register({
     const selectedElements = app.scene.getSelectedElements(appState);
     return zoomToFit({
       targetElements: selectedElements.length ? selectedElements : elements,
-      appState,
+      appState: {
+        ...appState,
+        userToFollow: null,
+      },
       fitToViewport: false,
     });
   },
@@ -341,7 +368,10 @@ export const actionZoomToFitSelection = register({
     const selectedElements = app.scene.getSelectedElements(appState);
     return zoomToFit({
       targetElements: selectedElements.length ? selectedElements : elements,
-      appState,
+      appState: {
+        ...appState,
+        userToFollow: null,
+      },
       fitToViewport: true,
     });
   },
@@ -358,7 +388,14 @@ export const actionZoomToFit = register({
   viewMode: true,
   trackEvent: { category: "canvas" },
   perform: (elements, appState) =>
-    zoomToFit({ targetElements: elements, appState, fitToViewport: false }),
+    zoomToFit({
+      targetElements: elements,
+      appState: {
+        ...appState,
+        userToFollow: null,
+      },
+      fitToViewport: false,
+    }),
   keyTest: (event) =>
     event.code === CODES.ONE &&
     event.shiftKey &&

+ 42 - 14
packages/excalidraw/actions/actionNavigate.tsx

@@ -1,6 +1,5 @@
 import { getClientColor } from "../clients";
 import { Avatar } from "../components/Avatar";
-import { centerScrollOn } from "../scene/scroll";
 import { Collaborator } from "../types";
 import { register } from "./register";
 
@@ -9,39 +8,68 @@ export const actionGoToCollaborator = register({
   viewMode: true,
   trackEvent: { category: "collab" },
   perform: (_elements, appState, value) => {
-    const point = value as Collaborator["pointer"];
+    const _value = value as Collaborator;
+    const point = _value.pointer;
+
     if (!point) {
       return { appState, commitToHistory: false };
     }
 
+    if (appState.userToFollow?.socketId === _value.socketId) {
+      return {
+        appState: {
+          ...appState,
+          userToFollow: null,
+        },
+        commitToHistory: false,
+      };
+    }
+
     return {
       appState: {
         ...appState,
-        ...centerScrollOn({
-          scenePoint: point,
-          viewportDimensions: {
-            width: appState.width,
-            height: appState.height,
-          },
-          zoom: appState.zoom,
-        }),
+        userToFollow: {
+          socketId: _value.socketId!,
+          username: _value.username || "",
+        },
         // Close mobile menu
         openMenu: appState.openMenu === "canvas" ? null : appState.openMenu,
       },
       commitToHistory: false,
     };
   },
-  PanelComponent: ({ updateData, data }) => {
-    const [clientId, collaborator] = data as [string, Collaborator];
+  PanelComponent: ({ updateData, data, appState }) => {
+    const [clientId, collaborator, withName] = data as [
+      string,
+      Collaborator,
+      boolean,
+    ];
 
     const background = getClientColor(clientId);
 
-    return (
+    return withName ? (
+      <div
+        className="dropdown-menu-item dropdown-menu-item-base"
+        onClick={() => updateData({ ...collaborator, clientId })}
+      >
+        <Avatar
+          color={background}
+          onClick={() => {}}
+          name={collaborator.username || ""}
+          src={collaborator.avatarUrl}
+          isBeingFollowed={appState.userToFollow?.socketId === clientId}
+        />
+        {collaborator.username}
+      </div>
+    ) : (
       <Avatar
         color={background}
-        onClick={() => updateData(collaborator.pointer)}
+        onClick={() => {
+          updateData({ ...collaborator, clientId });
+        }}
         name={collaborator.username || ""}
         src={collaborator.avatarUrl}
+        isBeingFollowed={appState.userToFollow?.socketId === clientId}
       />
     );
   },

+ 4 - 0
packages/excalidraw/appState.ts

@@ -105,6 +105,8 @@ export const getDefaultAppState = (): Omit<
       y: 0,
     },
     objectsSnapModeEnabled: false,
+    userToFollow: null,
+    followedBy: new Set(),
   };
 };
 
@@ -215,6 +217,8 @@ const APP_STATE_STORAGE_CONF = (<
   snapLines: { browser: false, export: false, server: false },
   originSnapOffset: { browser: false, export: false, server: false },
   objectsSnapModeEnabled: { browser: true, export: false, server: false },
+  userToFollow: { browser: false, export: false, server: false },
+  followedBy: { browser: false, export: false, server: false },
 });
 
 const _clearAppStateForStorage = <

+ 60 - 1
packages/excalidraw/components/App.tsx

@@ -244,6 +244,7 @@ import {
   KeyboardModifiersObject,
   CollaboratorPointer,
   ToolType,
+  OnUserFollowedPayload,
 } from "../types";
 import {
   debounce,
@@ -396,6 +397,7 @@ import { COLOR_PALETTE } from "../colors";
 import { ElementCanvasButton } from "./MagicButton";
 import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
 import { EditorLocalStorage } from "../data/EditorLocalStorage";
+import FollowMode from "./FollowMode/FollowMode";
 
 const AppContext = React.createContext<AppClassProperties>(null!);
 const AppPropsContext = React.createContext<AppProps>(null!);
@@ -551,6 +553,10 @@ class App extends React.Component<AppProps, AppState> {
       event: PointerEvent,
     ]
   >();
+  onUserFollowEmitter = new Emitter<[payload: OnUserFollowedPayload]>();
+  onScrollChangeEmitter = new Emitter<
+    [scrollX: number, scrollY: number, zoom: AppState["zoom"]]
+  >();
 
   constructor(props: AppProps) {
     super(props);
@@ -620,6 +626,8 @@ class App extends React.Component<AppProps, AppState> {
         onChange: (cb) => this.onChangeEmitter.on(cb),
         onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
         onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
+        onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
+        onUserFollow: (cb) => this.onUserFollowEmitter.on(cb),
       } as const;
       if (typeof excalidrawAPI === "function") {
         excalidrawAPI(api);
@@ -1582,6 +1590,14 @@ class App extends React.Component<AppProps, AppState> {
                           onPointerDown={this.handleCanvasPointerDown}
                           onDoubleClick={this.handleCanvasDoubleClick}
                         />
+                        {this.state.userToFollow && (
+                          <FollowMode
+                            width={this.state.width}
+                            height={this.state.height}
+                            userToFollow={this.state.userToFollow}
+                            onDisconnect={this.maybeUnfollowRemoteUser}
+                          />
+                        )}
                         {this.renderFrameNames()}
                       </ExcalidrawActionManagerContext.Provider>
                       {this.renderEmbeddables()}
@@ -2531,11 +2547,45 @@ class App extends React.Component<AppProps, AppState> {
       this.refreshEditorBreakpoints();
     }
 
+    const hasFollowedPersonLeft =
+      prevState.userToFollow &&
+      !this.state.collaborators.has(prevState.userToFollow.socketId);
+
+    if (hasFollowedPersonLeft) {
+      this.maybeUnfollowRemoteUser();
+    }
+
     if (
+      prevState.zoom.value !== this.state.zoom.value ||
       prevState.scrollX !== this.state.scrollX ||
       prevState.scrollY !== this.state.scrollY
     ) {
-      this.props?.onScrollChange?.(this.state.scrollX, this.state.scrollY);
+      this.props?.onScrollChange?.(
+        this.state.scrollX,
+        this.state.scrollY,
+        this.state.zoom,
+      );
+      this.onScrollChangeEmitter.trigger(
+        this.state.scrollX,
+        this.state.scrollY,
+        this.state.zoom,
+      );
+    }
+
+    if (prevState.userToFollow !== this.state.userToFollow) {
+      if (prevState.userToFollow) {
+        this.onUserFollowEmitter.trigger({
+          userToFollow: prevState.userToFollow,
+          action: "UNFOLLOW",
+        });
+      }
+
+      if (this.state.userToFollow) {
+        this.onUserFollowEmitter.trigger({
+          userToFollow: this.state.userToFollow,
+          action: "FOLLOW",
+        });
+      }
     }
 
     if (
@@ -3421,11 +3471,18 @@ class App extends React.Component<AppProps, AppState> {
     }
   };
 
+  private maybeUnfollowRemoteUser = () => {
+    if (this.state.userToFollow) {
+      this.setState({ userToFollow: null });
+    }
+  };
+
   /** use when changing scrollX/scrollY/zoom based on user interaction */
   private translateCanvas: React.Component<any, AppState>["setState"] = (
     state,
   ) => {
     this.cancelInProgresAnimation?.();
+    this.maybeUnfollowRemoteUser();
     this.setState(state);
   };
 
@@ -5154,6 +5211,8 @@ class App extends React.Component<AppProps, AppState> {
   private handleCanvasPointerDown = (
     event: React.PointerEvent<HTMLElement>,
   ) => {
+    this.maybeUnfollowRemoteUser();
+
     // since contextMenu options are potentially evaluated on each render,
     // and an contextMenu action may depend on selection state, we must
     // close the contextMenu before we update the selection on pointerDown

+ 1 - 29
packages/excalidraw/components/Avatar.scss

@@ -2,34 +2,6 @@
 
 .excalidraw {
   .Avatar {
-    width: 1.25rem;
-    height: 1.25rem;
-    position: relative;
-    border-radius: 100%;
-    outline-offset: 2px;
-    display: flex;
-    justify-content: center;
-    align-items: center;
-    cursor: pointer;
-    font-size: 0.75rem;
-    font-weight: 800;
-    line-height: 1;
-
-    &-img {
-      width: 100%;
-      height: 100%;
-      border-radius: 100%;
-    }
-
-    &::before {
-      content: "";
-      position: absolute;
-      top: -3px;
-      right: -3px;
-      bottom: -3px;
-      left: -3px;
-      border: 1px solid var(--avatar-border-color);
-      border-radius: 100%;
-    }
+    @include avatarStyles;
   }
 }

+ 14 - 2
packages/excalidraw/components/Avatar.tsx

@@ -2,21 +2,33 @@ import "./Avatar.scss";
 
 import React, { useState } from "react";
 import { getNameInitial } from "../clients";
+import clsx from "clsx";
 
 type AvatarProps = {
   onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
   color: string;
   name: string;
   src?: string;
+  isBeingFollowed?: boolean;
 };
 
-export const Avatar = ({ color, onClick, name, src }: AvatarProps) => {
+export const Avatar = ({
+  color,
+  onClick,
+  name,
+  src,
+  isBeingFollowed,
+}: AvatarProps) => {
   const shortName = getNameInitial(name);
   const [error, setError] = useState(false);
   const loadImg = !error && src;
   const style = loadImg ? undefined : { background: color };
   return (
-    <div className="Avatar" style={style} onClick={onClick}>
+    <div
+      className={clsx("Avatar", { "Avatar--is-followed": isBeingFollowed })}
+      style={style}
+      onClick={onClick}
+    >
       {loadImg ? (
         <img
           className="Avatar-img"

+ 59 - 0
packages/excalidraw/components/FollowMode/FollowMode.scss

@@ -0,0 +1,59 @@
+.excalidraw {
+  .follow-mode {
+    position: absolute;
+    box-sizing: border-box;
+    pointer-events: none;
+    border: 2px solid var(--color-primary-hover);
+    z-index: 9999;
+    display: flex;
+    align-items: flex-end;
+    justify-content: center;
+
+    &__badge {
+      background-color: var(--color-primary-hover);
+      color: var(--color-primary-light);
+      padding: 0.25rem 0.5rem;
+      margin-bottom: 0.5rem;
+      border-radius: 0.5rem;
+      pointer-events: all;
+      font-size: 0.75rem;
+      display: flex;
+      gap: 0.5rem;
+      align-items: center;
+
+      &__label {
+        display: flex;
+        white-space: pre-wrap;
+        line-height: 1;
+      }
+
+      &__username {
+        display: block;
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        max-width: 100px;
+      }
+    }
+
+    &__disconnect-btn {
+      all: unset;
+      cursor: pointer;
+      border-radius: 0.25rem;
+
+      &:hover {
+        background-color: var(--color-primary-darker);
+      }
+
+      &:active {
+        background-color: var(--color-primary-darkest);
+      }
+
+      svg {
+        display: block;
+        width: 1rem;
+        height: 1rem;
+      }
+    }
+  }
+}

+ 43 - 0
packages/excalidraw/components/FollowMode/FollowMode.tsx

@@ -0,0 +1,43 @@
+import { UserToFollow } from "../../types";
+import { CloseIcon } from "../icons";
+import "./FollowMode.scss";
+
+interface FollowModeProps {
+  width: number;
+  height: number;
+  userToFollow: UserToFollow;
+  onDisconnect: () => void;
+}
+
+const FollowMode = ({
+  height,
+  width,
+  userToFollow,
+  onDisconnect,
+}: FollowModeProps) => {
+  return (
+    <div style={{ position: "relative" }}>
+      <div className="follow-mode" style={{ width, height }}>
+        <div className="follow-mode__badge">
+          <div className="follow-mode__badge__label">
+            Following{" "}
+            <span
+              className="follow-mode__badge__username"
+              title={userToFollow.username}
+            >
+              {userToFollow.username}
+            </span>
+          </div>
+          <button
+            onClick={onDisconnect}
+            className="follow-mode__disconnect-btn"
+          >
+            {CloseIcon}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default FollowMode;

+ 4 - 0
packages/excalidraw/components/Sidebar/SidebarTrigger.scss

@@ -21,6 +21,10 @@
       width: var(--lg-icon-size);
       height: var(--lg-icon-size);
     }
+
+    &__label-element {
+      align-self: flex-start;
+    }
   }
 
   .default-sidebar-trigger .sidebar-trigger__label {

+ 1 - 1
packages/excalidraw/components/Sidebar/SidebarTrigger.tsx

@@ -19,7 +19,7 @@ export const SidebarTrigger = ({
   const appState = useUIAppState();
 
   return (
-    <label title={title}>
+    <label title={title} className="sidebar-trigger__label-element">
       <input
         className="ToolIcon_type_checkbox"
         type="checkbox"

+ 96 - 4
packages/excalidraw/components/UserList.scss

@@ -1,3 +1,5 @@
+@import "../css/variables.module";
+
 .excalidraw {
   .UserList {
     pointer-events: none;
@@ -14,11 +16,13 @@
       display: none;
     }
 
-    // can fit max 5 avatars in a column
-    max-height: 140px;
+    box-sizing: border-box;
+
+    // can fit max 4 avatars (3 avatars + show more) in a column
+    max-height: 120px;
 
-    // can fit max 10 avatars in a row when there's enough space
-    max-width: 290px;
+    // can fit max 4 avatars (3 avatars + show more) when there's enough space
+    max-width: 120px;
 
     // Tweak in 30px increments to fit more/fewer avatars in a row/column ^^
 
@@ -33,5 +37,93 @@
     padding: 0;
     justify-content: normal;
     margin: 0.5rem 0;
+    max-width: none;
+    max-height: none;
+  }
+
+  .UserList__more {
+    @include avatarStyles;
+    background-color: var(--color-gray-20);
+    border: 0 !important;
+    font-size: 0.5rem;
+    font-weight: 400;
+    flex-shrink: 0;
+    color: var(--color-gray-100);
+  }
+
+  --userlist-hint-bg-color: var(--color-gray-10);
+  --userlist-hint-heading-color: var(--color-gray-80);
+  --userlist-hint-text-color: var(--color-gray-60);
+  --userlist-collaborators-border-color: var(--color-gray-20);
+
+  &.theme--dark {
+    --userlist-hint-bg-color: var(--color-gray-90);
+    --userlist-hint-heading-color: var(--color-gray-30);
+    --userlist-hint-text-color: var(--color-gray-40);
+    --userlist-collaborators-border-color: var(--color-gray-80);
+  }
+
+  .UserList__collaborators {
+    position: static;
+    top: auto;
+    margin-top: 0;
+    max-height: 12rem;
+    overflow-y: auto;
+    padding: 0.25rem 0.5rem;
+    border-top: 1px solid var(--userlist-collaborators-border-color);
+    border-bottom: 1px solid var(--userlist-collaborators-border-color);
+
+    &__empty {
+      color: var(--color-gray-60);
+      font-size: 0.75rem;
+      line-height: 150%;
+      padding: 0.5rem 0;
+    }
+  }
+
+  .UserList__hint {
+    padding: 0.5rem 0.75rem;
+    overflow: hidden;
+    text-align: center;
+    color: var(--userlist-hint-text-color);
+    font-size: 0.75rem;
+    line-height: 150%;
+  }
+
+  .UserList__search-wrapper {
+    position: relative;
+    height: 2.5rem;
+
+    svg {
+      position: absolute;
+      top: 50%;
+      transform: translateY(-50%);
+      left: 0.75rem;
+      width: 1.25rem;
+      height: 1.25rem;
+      color: var(--color-gray-40);
+      z-index: 1;
+    }
+  }
+
+  .UserList__search {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    box-sizing: border-box;
+    border: 0 !important;
+    border-radius: 0 !important;
+    font-size: 0.875rem;
+    padding-left: 2.5rem !important;
+    padding-right: 0.75rem !important;
+
+    &::placeholder {
+      color: var(--color-gray-40);
+    }
+
+    &:focus {
+      box-shadow: none !important;
+    }
   }
 }

+ 221 - 38
packages/excalidraw/components/UserList.tsx

@@ -2,51 +2,234 @@ import "./UserList.scss";
 
 import React from "react";
 import clsx from "clsx";
-import { AppState, Collaborator } from "../types";
+import { Collaborator, SocketId } from "../types";
 import { Tooltip } from "./Tooltip";
 import { useExcalidrawActionManager } from "./App";
+import { ActionManager } from "../actions/manager";
 
-export const UserList: React.FC<{
+import * as Popover from "@radix-ui/react-popover";
+import { Island } from "./Island";
+import { searchIcon } from "./icons";
+import { t } from "../i18n";
+import { isShallowEqual } from "../utils";
+
+const FIRST_N_AVATARS = 3;
+const SHOW_COLLABORATORS_FILTER_AT = 8;
+
+const ConditionalTooltipWrapper = ({
+  shouldWrap,
+  children,
+  clientId,
+  username,
+}: {
+  shouldWrap: boolean;
+  children: React.ReactNode;
+  username?: string | null;
+  clientId: string;
+}) =>
+  shouldWrap ? (
+    <Tooltip label={username || "Unknown user"} key={clientId}>
+      {children}
+    </Tooltip>
+  ) : (
+    <React.Fragment key={clientId}>{children}</React.Fragment>
+  );
+
+const renderCollaborator = ({
+  actionManager,
+  collaborator,
+  clientId,
+  withName = false,
+  shouldWrapWithTooltip = false,
+}: {
+  actionManager: ActionManager;
+  collaborator: Collaborator;
+  clientId: string;
+  withName?: boolean;
+  shouldWrapWithTooltip?: boolean;
+}) => {
+  const avatarJSX = actionManager.renderAction("goToCollaborator", [
+    clientId,
+    collaborator,
+    withName,
+  ]);
+
+  return (
+    <ConditionalTooltipWrapper
+      key={clientId}
+      clientId={clientId}
+      username={collaborator.username}
+      shouldWrap={shouldWrapWithTooltip}
+    >
+      {avatarJSX}
+    </ConditionalTooltipWrapper>
+  );
+};
+
+type UserListUserObject = Pick<
+  Collaborator,
+  "avatarUrl" | "id" | "socketId" | "username"
+>;
+
+type UserListProps = {
   className?: string;
   mobile?: boolean;
-  collaborators: AppState["collaborators"];
-}> = ({ className, mobile, collaborators }) => {
-  const actionManager = useExcalidrawActionManager();
-
-  const uniqueCollaborators = new Map<string, Collaborator>();
-  collaborators.forEach((collaborator, socketId) => {
-    uniqueCollaborators.set(
-      // filter on user id, else fall back on unique socketId
-      collaborator.id || socketId,
-      collaborator,
+  collaborators: Map<SocketId, UserListUserObject>;
+};
+
+const collaboratorComparatorKeys = [
+  "avatarUrl",
+  "id",
+  "socketId",
+  "username",
+] as const;
+
+export const UserList = React.memo(
+  ({ className, mobile, collaborators }: UserListProps) => {
+    const actionManager = useExcalidrawActionManager();
+
+    const uniqueCollaboratorsMap = new Map<string, Collaborator>();
+
+    collaborators.forEach((collaborator, socketId) => {
+      uniqueCollaboratorsMap.set(
+        // filter on user id, else fall back on unique socketId
+        collaborator.id || socketId,
+        { ...collaborator, socketId },
+      );
+    });
+
+    // const uniqueCollaboratorsMap = sampleCollaborators;
+    const uniqueCollaboratorsArray = Array.from(uniqueCollaboratorsMap).filter(
+      ([_, collaborator]) => Object.keys(collaborator).length !== 1,
     );
-  });
-
-  const avatars =
-    uniqueCollaborators.size > 0 &&
-    Array.from(uniqueCollaborators)
-      .filter(([_, client]) => Object.keys(client).length !== 0)
-      .map(([clientId, collaborator]) => {
-        const avatarJSX = actionManager.renderAction("goToCollaborator", [
-          clientId,
+
+    const [searchTerm, setSearchTerm] = React.useState("");
+
+    if (uniqueCollaboratorsArray.length === 0) {
+      return null;
+    }
+
+    const searchTermNormalized = searchTerm.trim().toLowerCase();
+
+    const filteredCollaborators = searchTermNormalized
+      ? uniqueCollaboratorsArray.filter(([, collaborator]) =>
+          collaborator.username?.toLowerCase().includes(searchTerm),
+        )
+      : uniqueCollaboratorsArray;
+
+    const firstNCollaborators = uniqueCollaboratorsArray.slice(
+      0,
+      FIRST_N_AVATARS,
+    );
+
+    const firstNAvatarsJSX = firstNCollaborators.map(
+      ([clientId, collaborator]) =>
+        renderCollaborator({
+          actionManager,
           collaborator,
-        ]);
+          clientId,
+          shouldWrapWithTooltip: true,
+        }),
+    );
 
-        return mobile ? (
-          <Tooltip
-            label={collaborator.username || "Unknown user"}
-            key={clientId}
+    return mobile ? (
+      <div className={clsx("UserList UserList_mobile", className)}>
+        {uniqueCollaboratorsArray.map(([clientId, collaborator]) =>
+          renderCollaborator({
+            actionManager,
+            collaborator,
+            clientId,
+            shouldWrapWithTooltip: true,
+          }),
+        )}
+      </div>
+    ) : (
+      <div className={clsx("UserList", className)}>
+        {firstNAvatarsJSX}
+
+        {uniqueCollaboratorsArray.length > FIRST_N_AVATARS && (
+          <Popover.Root
+            onOpenChange={(isOpen) => {
+              if (!isOpen) {
+                setSearchTerm("");
+              }
+            }}
           >
-            {avatarJSX}
-          </Tooltip>
-        ) : (
-          <React.Fragment key={clientId}>{avatarJSX}</React.Fragment>
-        );
-      });
+            <Popover.Trigger className="UserList__more">
+              +{uniqueCollaboratorsArray.length - FIRST_N_AVATARS}
+            </Popover.Trigger>
+            <Popover.Content
+              style={{
+                zIndex: 2,
+                width: "12rem",
+                textAlign: "left",
+              }}
+              align="end"
+              sideOffset={10}
+            >
+              <Island style={{ overflow: "hidden" }}>
+                {uniqueCollaboratorsArray.length >=
+                  SHOW_COLLABORATORS_FILTER_AT && (
+                  <div className="UserList__search-wrapper">
+                    {searchIcon}
+                    <input
+                      className="UserList__search"
+                      type="text"
+                      placeholder={t("userList.search.placeholder")}
+                      value={searchTerm}
+                      onChange={(e) => {
+                        setSearchTerm(e.target.value);
+                      }}
+                    />
+                  </div>
+                )}
+                <div className="dropdown-menu UserList__collaborators">
+                  {filteredCollaborators.length === 0 && (
+                    <div className="UserList__collaborators__empty">
+                      {t("userList.search.empty")}
+                    </div>
+                  )}
+                  <div className="UserList__hint">
+                    {t("userList.hint.text")}
+                  </div>
+                  {filteredCollaborators.map(([clientId, collaborator]) =>
+                    renderCollaborator({
+                      actionManager,
+                      collaborator,
+                      clientId,
+                      withName: true,
+                    }),
+                  )}
+                </div>
+              </Island>
+            </Popover.Content>
+          </Popover.Root>
+        )}
+      </div>
+    );
+  },
+  (prev, next) => {
+    if (
+      prev.collaborators.size !== next.collaborators.size ||
+      prev.mobile !== next.mobile ||
+      prev.className !== next.className
+    ) {
+      return false;
+    }
 
-  return (
-    <div className={clsx("UserList", className, { UserList_mobile: mobile })}>
-      {avatars}
-    </div>
-  );
-};
+    for (const [socketId, collaborator] of prev.collaborators) {
+      const nextCollaborator = next.collaborators.get(socketId);
+      if (
+        !nextCollaborator ||
+        !isShallowEqual(
+          collaborator,
+          nextCollaborator,
+          collaboratorComparatorKeys,
+        )
+      ) {
+        return false;
+      }
+    }
+    return true;
+  },
+);

+ 9 - 0
packages/excalidraw/components/icons.tsx

@@ -1823,3 +1823,12 @@ export const brainIcon = createIcon(
   </g>,
   tablerIconProps,
 );
+
+export const searchIcon = createIcon(
+  <g strokeWidth={1.5}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0" />
+    <path d="M21 21l-6 -6" />
+  </g>,
+  tablerIconProps,
+);

+ 2 - 0
packages/excalidraw/css/theme.scss

@@ -84,6 +84,7 @@
   --color-primary-darkest: #4a47b1;
   --color-primary-light: #e3e2fe;
   --color-primary-light-darker: #d7d5ff;
+  --color-primary-hover: #5753d0;
 
   --color-gray-10: #f5f5f5;
   --color-gray-20: #ebebeb;
@@ -205,6 +206,7 @@
     --color-primary-darkest: #beb9ff;
     --color-primary-light: #4f4d6f;
     --color-primary-light-darker: #43415e;
+    --color-primary-hover: #bbb8ff;
 
     --color-text-warning: var(--color-gray-80);
 

+ 38 - 0
packages/excalidraw/css/variables.module.scss

@@ -114,6 +114,44 @@
   }
 }
 
+@mixin avatarStyles {
+  width: 1.25rem;
+  height: 1.25rem;
+  position: relative;
+  border-radius: 100%;
+  outline-offset: 2px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  cursor: pointer;
+  font-size: 0.75rem;
+  font-weight: 800;
+  line-height: 1;
+  color: var(--color-gray-90);
+  flex: 0 0 auto;
+
+  &-img {
+    width: 100%;
+    height: 100%;
+    border-radius: 100%;
+  }
+
+  &::before {
+    content: "";
+    position: absolute;
+    top: -3px;
+    right: -3px;
+    bottom: -3px;
+    left: -3px;
+    border: 1px solid var(--avatar-border-color);
+    border-radius: 100%;
+  }
+
+  &--is-followed::before {
+    border-color: var(--color-primary-hover);
+  }
+}
+
 @mixin filledButtonOnCanvas {
   border: none;
   box-shadow: 0 0 0 1px var(--color-surface-lowest);

+ 1 - 0
packages/excalidraw/index.tsx

@@ -247,6 +247,7 @@ export { TTDDialog } from "./components/TTDDialog/TTDDialog";
 export { TTDDialogTrigger } from "./components/TTDDialog/TTDDialogTrigger";
 
 export { normalizeLink } from "./data/url";
+export { zoomToFitBounds } from "./actions/actionCanvas";
 export { convertToExcalidrawElements } from "./data/transform";
 export { getCommonBounds } from "./element/bounds";
 

+ 9 - 0
packages/excalidraw/locales/en.json

@@ -521,5 +521,14 @@
     "description": "Currently only <flowchartLink>Flowchart</flowchartLink>,<sequenceLink> Sequence, </sequenceLink> and <classLink>Class </classLink>Diagrams are supported. The other types will be rendered as image in Excalidraw.",
     "syntax": "Mermaid Syntax",
     "preview": "Preview"
+  },
+  "userList": {
+    "search": {
+      "placeholder": "Quick search",
+      "empty": "No users found"
+    },
+    "hint": {
+      "text": "Click on user to follow"
+    }
   }
 }

+ 34 - 0
packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -312,6 +312,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -370,6 +371,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 200,
@@ -511,6 +513,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -565,6 +568,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
   "toast": {
     "message": "Added to library",
   },
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 200,
@@ -713,6 +717,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -765,6 +770,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 200,
@@ -1089,6 +1095,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -1141,6 +1148,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 200,
@@ -1465,6 +1473,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -1519,6 +1528,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
   "toast": {
     "message": "Copied styles.",
   },
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 200,
@@ -1667,6 +1677,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -1717,6 +1728,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 200,
@@ -1906,6 +1918,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -1958,6 +1971,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 200,
@@ -2210,6 +2224,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -2267,6 +2282,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 200,
@@ -2602,6 +2618,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -2656,6 +2673,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
   "toast": {
     "message": "Copied styles.",
   },
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 200,
@@ -3412,6 +3430,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -3464,6 +3483,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 200,
@@ -3788,6 +3808,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -3840,6 +3861,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 200,
@@ -4164,6 +4186,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -4219,6 +4242,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 200,
@@ -4896,6 +4920,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -4951,6 +4976,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 200,
@@ -5476,6 +5502,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -5533,6 +5560,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 200,
@@ -5995,6 +6023,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -6048,6 +6077,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 200,
@@ -6394,6 +6424,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -6446,6 +6477,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 200,
@@ -6768,6 +6800,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -6823,6 +6856,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 200,

+ 104 - 0
packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap

@@ -39,6 +39,7 @@ exports[`given element A and group of elements B and given both are selected whe
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -98,6 +99,7 @@ exports[`given element A and group of elements B and given both are selected whe
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -495,6 +497,7 @@ exports[`given element A and group of elements B and given both are selected whe
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -556,6 +559,7 @@ exports[`given element A and group of elements B and given both are selected whe
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -953,6 +957,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -1005,6 +1010,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -1784,6 +1790,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -1838,6 +1845,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -1997,6 +2005,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -2054,6 +2063,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -2451,6 +2461,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -2505,6 +2516,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -2693,6 +2705,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -2745,6 +2758,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -2861,6 +2875,7 @@ exports[`regression tests > can drag element that covers another element, while
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -2915,6 +2930,7 @@ exports[`regression tests > can drag element that covers another element, while
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -3305,6 +3321,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -3357,6 +3374,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -3602,6 +3620,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -3656,6 +3675,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -3847,6 +3867,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -3901,6 +3922,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -4103,6 +4125,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -4157,6 +4180,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -4345,6 +4369,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -4400,6 +4425,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -4689,6 +4715,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -4743,6 +4770,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -5192,6 +5220,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -5273,6 +5302,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -5489,6 +5519,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -5542,6 +5573,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -5758,6 +5790,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -5838,6 +5871,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -5954,6 +5988,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -6006,6 +6041,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -6122,6 +6158,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -6174,6 +6211,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -6574,6 +6612,7 @@ exports[`regression tests > drags selected elements from point inside common bou
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -6630,6 +6669,7 @@ exports[`regression tests > drags selected elements from point inside common bou
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -6891,6 +6931,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -6941,6 +6982,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -8958,6 +9000,7 @@ exports[`regression tests > given a group of selected elements with an element t
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -9013,6 +9056,7 @@ exports[`regression tests > given a group of selected elements with an element t
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -9302,6 +9346,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -9357,6 +9402,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -9545,6 +9591,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -9599,6 +9646,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -9744,6 +9792,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -9798,6 +9847,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -10015,6 +10065,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -10067,6 +10118,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -10183,6 +10235,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -10235,6 +10288,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -10351,6 +10405,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -10403,6 +10458,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -10519,6 +10575,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -10594,6 +10651,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -10725,6 +10783,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -10800,6 +10859,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -10931,6 +10991,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -10981,6 +11042,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -11117,6 +11179,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -11192,6 +11255,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -11323,6 +11387,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -11375,6 +11440,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -11491,6 +11557,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -11566,6 +11633,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -11697,6 +11765,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -11749,6 +11818,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -11865,6 +11935,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -11915,6 +11986,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -12051,6 +12123,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -12103,6 +12176,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -12219,6 +12293,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -12279,6 +12354,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -12883,6 +12959,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -12937,6 +13014,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -13125,6 +13203,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -13175,6 +13254,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -13248,6 +13328,7 @@ exports[`regression tests > shift click on selected element should deselect it o
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -13300,6 +13381,7 @@ exports[`regression tests > shift click on selected element should deselect it o
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -13416,6 +13498,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -13472,6 +13555,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -13733,6 +13817,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -13791,6 +13876,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -14294,6 +14380,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -14356,6 +14443,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -15150,6 +15238,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -15203,6 +15292,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -15276,6 +15366,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -15330,6 +15421,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -16091,6 +16183,7 @@ exports[`regression tests > switches from group of selected elements to another
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -16174,6 +16267,7 @@ exports[`regression tests > switches from group of selected elements to another
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -16491,6 +16585,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -16573,6 +16668,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -16761,6 +16857,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -16811,6 +16908,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -16884,6 +16982,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -16936,6 +17035,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -17367,6 +17467,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -17417,6 +17518,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,
@@ -17490,6 +17592,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -17543,6 +17646,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "width": 1024,

+ 3 - 0
packages/excalidraw/tests/packages/__snapshots__/utils.test.ts.snap

@@ -9,6 +9,7 @@ exports[`exportToSvg > with default arguments 1`] = `
     "locked": false,
     "type": "selection",
   },
+  "amIBeingFollowed": false,
   "collaborators": Map {},
   "contextMenu": null,
   "currentChartType": "bar",
@@ -82,6 +83,7 @@ exports[`exportToSvg > with default arguments 1`] = `
   "selectedLinearElement": null,
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
+  "shouldDisconnectFollowModeOnCanvasInteraction": true,
   "showHyperlinkPopup": false,
   "showStats": false,
   "showWelcomeScreen": false,
@@ -90,6 +92,7 @@ exports[`exportToSvg > with default arguments 1`] = `
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "zenModeEnabled": false,

+ 27 - 4
packages/excalidraw/types.ts

@@ -41,7 +41,9 @@ import { Merge, ValueOf } from "./utility-types";
 
 export type Point = Readonly<RoughPoint>;
 
-export type Collaborator = {
+export type SocketId = string;
+
+export type Collaborator = Readonly<{
   pointer?: CollaboratorPointer;
   button?: "up" | "down";
   selectedElementIds?: AppState["selectedElementIds"];
@@ -56,7 +58,8 @@ export type Collaborator = {
   avatarUrl?: string;
   // user id. If supplied, we'll filter out duplicates when rendering user avatars.
   id?: string;
-};
+  socketId?: SocketId;
+}>;
 
 export type CollaboratorPointer = {
   x: number;
@@ -123,6 +126,11 @@ export type ActiveTool =
 export type SidebarName = string;
 export type SidebarTabName = string;
 
+export type UserToFollow = {
+  socketId: string;
+  username: string;
+};
+
 type _CommonCanvasAppState = {
   zoom: AppState["zoom"];
   scrollX: AppState["scrollX"];
@@ -303,13 +311,16 @@ export interface AppState {
   pendingImageElementId: ExcalidrawImageElement["id"] | null;
   showHyperlinkPopup: false | "info" | "editor";
   selectedLinearElement: LinearElementEditor | null;
-
   snapLines: readonly SnapLine[];
   originSnapOffset: {
     x: number;
     y: number;
   } | null;
   objectsSnapModeEnabled: boolean;
+  /** the user's clientId & username who is being followed on the canvas */
+  userToFollow: UserToFollow | null;
+  /** the clientIds of the users following the current user */
+  followedBy: Set<string>;
 }
 
 export type UIAppState = Omit<
@@ -385,6 +396,11 @@ export type ExcalidrawInitialDataState = Merge<
   }
 >;
 
+export type OnUserFollowedPayload = {
+  userToFollow: UserToFollow;
+  action: "FOLLOW" | "UNFOLLOW";
+};
+
 export interface ExcalidrawProps {
   onChange?: (
     elements: readonly ExcalidrawElement[],
@@ -438,7 +454,8 @@ export interface ExcalidrawProps {
     activeTool: AppState["activeTool"],
     pointerDownState: PointerDownState,
   ) => void;
-  onScrollChange?: (scrollX: number, scrollY: number) => void;
+  onScrollChange?: (scrollX: number, scrollY: number, zoom: Zoom) => void;
+  onUserFollow?: (payload: OnUserFollowedPayload) => void;
   children?: React.ReactNode;
   validateEmbeddable?:
     | boolean
@@ -675,6 +692,12 @@ export type ExcalidrawImperativeAPI = {
       event: PointerEvent,
     ) => void,
   ) => UnsubscribeCallback;
+  onScrollChange: (
+    callback: (scrollX: number, scrollY: number, zoom: Zoom) => void,
+  ) => UnsubscribeCallback;
+  onUserFollow: (
+    callback: (payload: OnUserFollowedPayload) => void,
+  ) => UnsubscribeCallback;
 };
 
 export type Device = Readonly<{

+ 34 - 3
packages/excalidraw/utils.ts

@@ -771,11 +771,21 @@ export const queryFocusableElements = (container: HTMLElement | null) => {
 
 export const isShallowEqual = <
   T extends Record<string, any>,
-  I extends keyof T,
+  K extends readonly unknown[],
 >(
   objA: T,
   objB: T,
-  comparators?: Record<I, (a: T[I], b: T[I]) => boolean>,
+  comparators?:
+    | { [key in keyof T]?: (a: T[key], b: T[key]) => boolean }
+    | (keyof T extends K[number]
+        ? K extends readonly (keyof T)[]
+          ? K
+          : {
+              _error: "keys are either missing or include keys not in compared obj";
+            }
+        : {
+            _error: "keys are either missing or include keys not in compared obj";
+          }),
   debug = false,
 ) => {
   const aKeys = Object.keys(objA);
@@ -783,8 +793,29 @@ export const isShallowEqual = <
   if (aKeys.length !== bKeys.length) {
     return false;
   }
+
+  if (comparators && Array.isArray(comparators)) {
+    for (const key of comparators) {
+      const ret = objA[key] === objB[key];
+      if (!ret) {
+        if (debug) {
+          console.info(
+            `%cisShallowEqual: ${key} not equal ->`,
+            "color: #8B4000",
+            objA[key],
+            objB[key],
+          );
+        }
+        return false;
+      }
+    }
+    return true;
+  }
+
   return aKeys.every((key) => {
-    const comparator = comparators?.[key as I];
+    const comparator = (
+      comparators as { [key in keyof T]?: (a: T[key], b: T[key]) => boolean }
+    )?.[key as keyof T];
     const ret = comparator
       ? comparator(objA[key], objB[key])
       : objA[key] === objB[key];

+ 2 - 0
packages/utils/__snapshots__/export.test.ts.snap

@@ -40,6 +40,7 @@ exports[`exportToSvg > with default arguments 1`] = `
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -90,6 +91,7 @@ exports[`exportToSvg > with default arguments 1`] = `
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "zenModeEnabled": false,

+ 2 - 0
packages/utils/__snapshots__/utils.test.ts.snap

@@ -40,6 +40,7 @@ exports[`exportToSvg > with default arguments 1`] = `
   "exportScale": 1,
   "exportWithDarkMode": false,
   "fileHandle": null,
+  "followedBy": Set {},
   "frameRendering": {
     "clip": true,
     "enabled": true,
@@ -90,6 +91,7 @@ exports[`exportToSvg > with default arguments 1`] = `
   "suggestedBindings": [],
   "theme": "light",
   "toast": null,
+  "userToFollow": null,
   "viewBackgroundColor": "#ffffff",
   "viewModeEnabled": false,
   "zenModeEnabled": false,