Browse Source

feat: expose more collaborator status icons (#7777)

David Luzar 1 year ago
parent
commit
068895db0e

+ 1 - 1
excalidraw-app/index.scss

@@ -8,7 +8,7 @@
   .top-right-ui {
     display: flex;
     justify-content: center;
-    align-items: center;
+    align-items: flex-start;
   }
 
   .footer-center {

+ 69 - 23
packages/excalidraw/actions/actionNavigate.tsx

@@ -1,10 +1,15 @@
 import { getClientColor } from "../clients";
 import { Avatar } from "../components/Avatar";
 import { GoToCollaboratorComponentProps } from "../components/UserList";
-import { eyeIcon } from "../components/icons";
+import {
+  eyeIcon,
+  microphoneIcon,
+  microphoneMutedIcon,
+} from "../components/icons";
 import { t } from "../i18n";
 import { Collaborator } from "../types";
 import { register } from "./register";
+import clsx from "clsx";
 
 export const actionGoToCollaborator = register({
   name: "goToCollaborator",
@@ -39,14 +44,45 @@ export const actionGoToCollaborator = register({
     };
   },
   PanelComponent: ({ updateData, data, appState }) => {
-    const { clientId, collaborator, withName, isBeingFollowed } =
+    const { socketId, collaborator, withName, isBeingFollowed } =
       data as GoToCollaboratorComponentProps;
 
-    const background = getClientColor(clientId);
+    const background = getClientColor(socketId, collaborator);
+
+    const statusClassNames = clsx({
+      "is-followed": isBeingFollowed,
+      "is-current-user": collaborator.isCurrentUser === true,
+      "is-speaking": collaborator.isSpeaking,
+      "is-in-call": collaborator.isInCall,
+      "is-muted": collaborator.isMuted,
+    });
+
+    const statusIconJSX = collaborator.isInCall ? (
+      collaborator.isSpeaking ? (
+        <div
+          className="UserList__collaborator-status-icon-speaking-indicator"
+          title={t("userList.hint.isSpeaking")}
+        >
+          <div />
+          <div />
+          <div />
+        </div>
+      ) : collaborator.isMuted ? (
+        <div
+          className="UserList__collaborator-status-icon-microphone-muted"
+          title={t("userList.hint.micMuted")}
+        >
+          {microphoneMutedIcon}
+        </div>
+      ) : (
+        <div title={t("userList.hint.inCall")}>{microphoneIcon}</div>
+      )
+    ) : null;
 
     return withName ? (
       <div
-        className="dropdown-menu-item dropdown-menu-item-base UserList__collaborator"
+        className={`dropdown-menu-item dropdown-menu-item-base UserList__collaborator ${statusClassNames}`}
+        style={{ [`--avatar-size` as any]: "1.5rem" }}
         onClick={() => updateData<Collaborator>(collaborator)}
       >
         <Avatar
@@ -54,32 +90,42 @@ export const actionGoToCollaborator = register({
           onClick={() => {}}
           name={collaborator.username || ""}
           src={collaborator.avatarUrl}
-          isBeingFollowed={isBeingFollowed}
-          isCurrentUser={collaborator.isCurrentUser === true}
+          className={statusClassNames}
         />
         <div className="UserList__collaborator-name">
           {collaborator.username}
         </div>
-        <div
-          className="UserList__collaborator-follow-status-icon"
-          style={{ visibility: isBeingFollowed ? "visible" : "hidden" }}
-          title={isBeingFollowed ? t("userList.hint.followStatus") : undefined}
-          aria-hidden
-        >
-          {eyeIcon}
+        <div className="UserList__collaborator-status-icons" aria-hidden>
+          {isBeingFollowed && (
+            <div
+              className="UserList__collaborator-status-icon-is-followed"
+              title={t("userList.hint.followStatus")}
+            >
+              {eyeIcon}
+            </div>
+          )}
+          {statusIconJSX}
         </div>
       </div>
     ) : (
-      <Avatar
-        color={background}
-        onClick={() => {
-          updateData(collaborator);
-        }}
-        name={collaborator.username || ""}
-        src={collaborator.avatarUrl}
-        isBeingFollowed={isBeingFollowed}
-        isCurrentUser={collaborator.isCurrentUser === true}
-      />
+      <div
+        className={`UserList__collaborator UserList__collaborator--avatar-only ${statusClassNames}`}
+      >
+        <Avatar
+          color={background}
+          onClick={() => {
+            updateData(collaborator);
+          }}
+          name={collaborator.username || ""}
+          src={collaborator.avatarUrl}
+          className={statusClassNames}
+        />
+        {statusIconJSX && (
+          <div className="UserList__collaborator-status-icon">
+            {statusIconJSX}
+          </div>
+        )}
+      </div>
     );
   },
 });

+ 224 - 5
packages/excalidraw/clients.ts

@@ -1,3 +1,18 @@
+import {
+  COLOR_CHARCOAL_BLACK,
+  COLOR_VOICE_CALL,
+  COLOR_WHITE,
+  THEME,
+} from "./constants";
+import { roundRect } from "./renderer/roundRect";
+import { InteractiveCanvasRenderConfig } from "./scene/types";
+import {
+  Collaborator,
+  InteractiveCanvasAppState,
+  SocketId,
+  UserIdleState,
+} from "./types";
+
 function hashToInteger(id: string) {
   let hash = 0;
   if (id.length === 0) {
@@ -11,14 +26,12 @@ function hashToInteger(id: string) {
 }
 
 export const getClientColor = (
-  /**
-   * any uniquely identifying key, such as user id or socket id
-   */
-  id: string,
+  socketId: SocketId,
+  collaborator: Collaborator | undefined,
 ) => {
   // to get more even distribution in case `id` is not uniformly distributed to
   // begin with, we hash it
-  const hash = Math.abs(hashToInteger(id));
+  const hash = Math.abs(hashToInteger(collaborator?.id || socketId));
   // we want to get a multiple of 10 number in the range of 0-360 (in other
   // words a hue value of step size 10). There are 37 such values including 0.
   const hue = (hash % 37) * 10;
@@ -38,3 +51,209 @@ export const getNameInitial = (name?: string | null) => {
     firstCodePoint ? String.fromCodePoint(firstCodePoint) : "?"
   ).toUpperCase();
 };
+
+export const renderRemoteCursors = ({
+  context,
+  renderConfig,
+  appState,
+  normalizedWidth,
+  normalizedHeight,
+}: {
+  context: CanvasRenderingContext2D;
+  renderConfig: InteractiveCanvasRenderConfig;
+  appState: InteractiveCanvasAppState;
+  normalizedWidth: number;
+  normalizedHeight: number;
+}) => {
+  // Paint remote pointers
+  for (const [socketId, pointer] of renderConfig.remotePointerViewportCoords) {
+    let { x, y } = pointer;
+
+    const collaborator = appState.collaborators.get(socketId);
+
+    x -= appState.offsetLeft;
+    y -= appState.offsetTop;
+
+    const width = 11;
+    const height = 14;
+
+    const isOutOfBounds =
+      x < 0 ||
+      x > normalizedWidth - width ||
+      y < 0 ||
+      y > normalizedHeight - height;
+
+    x = Math.max(x, 0);
+    x = Math.min(x, normalizedWidth - width);
+    y = Math.max(y, 0);
+    y = Math.min(y, normalizedHeight - height);
+
+    const background = getClientColor(socketId, collaborator);
+
+    context.save();
+    context.strokeStyle = background;
+    context.fillStyle = background;
+
+    const userState = renderConfig.remotePointerUserStates.get(socketId);
+    const isInactive =
+      isOutOfBounds ||
+      userState === UserIdleState.IDLE ||
+      userState === UserIdleState.AWAY;
+
+    if (isInactive) {
+      context.globalAlpha = 0.3;
+    }
+
+    if (renderConfig.remotePointerButton.get(socketId) === "down") {
+      context.beginPath();
+      context.arc(x, y, 15, 0, 2 * Math.PI, false);
+      context.lineWidth = 3;
+      context.strokeStyle = "#ffffff88";
+      context.stroke();
+      context.closePath();
+
+      context.beginPath();
+      context.arc(x, y, 15, 0, 2 * Math.PI, false);
+      context.lineWidth = 1;
+      context.strokeStyle = background;
+      context.stroke();
+      context.closePath();
+    }
+
+    // TODO remove the dark theme color after we stop inverting canvas colors
+    const IS_SPEAKING_COLOR =
+      appState.theme === THEME.DARK ? "#2f6330" : COLOR_VOICE_CALL;
+
+    const isSpeaking = collaborator?.isSpeaking;
+
+    if (isSpeaking) {
+      // cursor outline for currently speaking user
+      context.fillStyle = IS_SPEAKING_COLOR;
+      context.strokeStyle = IS_SPEAKING_COLOR;
+      context.lineWidth = 10;
+      context.lineJoin = "round";
+      context.beginPath();
+      context.moveTo(x, y);
+      context.lineTo(x + 0, y + 14);
+      context.lineTo(x + 4, y + 9);
+      context.lineTo(x + 11, y + 8);
+      context.closePath();
+      context.stroke();
+      context.fill();
+    }
+
+    // Background (white outline) for arrow
+    context.fillStyle = COLOR_WHITE;
+    context.strokeStyle = COLOR_WHITE;
+    context.lineWidth = 6;
+    context.lineJoin = "round";
+    context.beginPath();
+    context.moveTo(x, y);
+    context.lineTo(x + 0, y + 14);
+    context.lineTo(x + 4, y + 9);
+    context.lineTo(x + 11, y + 8);
+    context.closePath();
+    context.stroke();
+    context.fill();
+
+    // Arrow
+    context.fillStyle = background;
+    context.strokeStyle = background;
+    context.lineWidth = 2;
+    context.lineJoin = "round";
+    context.beginPath();
+    if (isInactive) {
+      context.moveTo(x - 1, y - 1);
+      context.lineTo(x - 1, y + 15);
+      context.lineTo(x + 5, y + 10);
+      context.lineTo(x + 12, y + 9);
+      context.closePath();
+      context.fill();
+    } else {
+      context.moveTo(x, y);
+      context.lineTo(x + 0, y + 14);
+      context.lineTo(x + 4, y + 9);
+      context.lineTo(x + 11, y + 8);
+      context.closePath();
+      context.fill();
+      context.stroke();
+    }
+
+    const username = renderConfig.remotePointerUsernames.get(socketId) || "";
+
+    if (!isOutOfBounds && username) {
+      context.font = "600 12px sans-serif"; // font has to be set before context.measureText()
+
+      const offsetX = (isSpeaking ? x + 0 : x) + width / 2;
+      const offsetY = (isSpeaking ? y + 0 : y) + height + 2;
+      const paddingHorizontal = 5;
+      const paddingVertical = 3;
+      const measure = context.measureText(username);
+      const measureHeight =
+        measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent;
+      const finalHeight = Math.max(measureHeight, 12);
+
+      const boxX = offsetX - 1;
+      const boxY = offsetY - 1;
+      const boxWidth = measure.width + 2 + paddingHorizontal * 2 + 2;
+      const boxHeight = finalHeight + 2 + paddingVertical * 2 + 2;
+      if (context.roundRect) {
+        context.beginPath();
+        context.roundRect(boxX, boxY, boxWidth, boxHeight, 8);
+        context.fillStyle = background;
+        context.fill();
+        context.strokeStyle = COLOR_WHITE;
+        context.stroke();
+
+        if (isSpeaking) {
+          context.beginPath();
+          context.roundRect(boxX - 2, boxY - 2, boxWidth + 4, boxHeight + 4, 8);
+          context.strokeStyle = IS_SPEAKING_COLOR;
+          context.stroke();
+        }
+      } else {
+        roundRect(context, boxX, boxY, boxWidth, boxHeight, 8, COLOR_WHITE);
+      }
+      context.fillStyle = COLOR_CHARCOAL_BLACK;
+
+      context.fillText(
+        username,
+        offsetX + paddingHorizontal + 1,
+        offsetY +
+          paddingVertical +
+          measure.actualBoundingBoxAscent +
+          Math.floor((finalHeight - measureHeight) / 2) +
+          2,
+      );
+
+      // draw three vertical bars signalling someone is speaking
+      if (isSpeaking) {
+        context.fillStyle = IS_SPEAKING_COLOR;
+        const barheight = 8;
+        const margin = 8;
+        const gap = 5;
+        context.fillRect(
+          boxX + boxWidth + margin,
+          boxY + (boxHeight / 2 - barheight / 2),
+          2,
+          barheight,
+        );
+        context.fillRect(
+          boxX + boxWidth + margin + gap,
+          boxY + (boxHeight / 2 - (barheight * 2) / 2),
+          2,
+          barheight * 2,
+        );
+        context.fillRect(
+          boxX + boxWidth + margin + gap * 2,
+          boxY + (boxHeight / 2 - barheight / 2),
+          2,
+          barheight,
+        );
+      }
+    }
+
+    context.restore();
+    context.closePath();
+  }
+};

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

@@ -89,6 +89,7 @@ import {
   TOOL_TYPE,
   EDITOR_LS_KEYS,
   isIOS,
+  supportsResizeObserver,
 } from "../constants";
 import { ExportedElements, exportCanvas, loadFromBlob } from "../data";
 import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
@@ -476,9 +477,6 @@ export const useExcalidrawSetAppState = () =>
 export const useExcalidrawActionManager = () =>
   useContext(ExcalidrawActionManagerContext);
 
-const supportsResizeObserver =
-  typeof window !== "undefined" && "ResizeObserver" in window;
-
 let didTapTwice: boolean = false;
 let tappedTwiceTimer = 0;
 let isHoldingSpace: boolean = false;

+ 3 - 12
packages/excalidraw/components/Avatar.tsx

@@ -9,8 +9,7 @@ type AvatarProps = {
   color: string;
   name: string;
   src?: string;
-  isBeingFollowed?: boolean;
-  isCurrentUser: boolean;
+  className?: string;
 };
 
 export const Avatar = ({
@@ -18,22 +17,14 @@ export const Avatar = ({
   onClick,
   name,
   src,
-  isBeingFollowed,
-  isCurrentUser,
+  className,
 }: AvatarProps) => {
   const shortName = getNameInitial(name);
   const [error, setError] = useState(false);
   const loadImg = !error && src;
   const style = loadImg ? undefined : { background: color };
   return (
-    <div
-      className={clsx("Avatar", {
-        "Avatar--is-followed": isBeingFollowed,
-        "Avatar--is-current-user": isCurrentUser,
-      })}
-      style={style}
-      onClick={onClick}
-    >
+    <div className={clsx("Avatar", className)} style={style} onClick={onClick}>
       {loadImg ? (
         <img
           className="Avatar-img"

+ 7 - 0
packages/excalidraw/components/LayerUI.scss

@@ -19,7 +19,14 @@
 
     &__top-right {
       display: flex;
+      width: 100%;
+      justify-content: flex-end;
       gap: 0.75rem;
+      pointer-events: none !important;
+
+      & > * {
+        pointer-events: var(--ui-pointerEvents);
+      }
     }
 
     &__footer {

+ 94 - 14
packages/excalidraw/components/UserList.scss

@@ -1,16 +1,25 @@
 @import "../css/variables.module";
 
 .excalidraw {
+  --avatar-size: 1.75rem;
+  --avatarList-gap: 0.625rem;
+  --userList-padding: var(--space-factor);
+
+  .UserList-wrapper {
+    display: flex;
+    width: 100%;
+    justify-content: flex-end;
+    pointer-events: none !important;
+  }
+
   .UserList {
     pointer-events: none;
-    /*github corner*/
-    padding: var(--space-factor) var(--space-factor) var(--space-factor)
-      var(--space-factor);
+    padding: var(--userList-padding);
     display: flex;
     flex-wrap: wrap;
     justify-content: flex-end;
     align-items: center;
-    gap: 0.625rem;
+    gap: var(--avatarList-gap);
 
     &:empty {
       display: none;
@@ -18,15 +27,16 @@
 
     box-sizing: border-box;
 
-    // can fit max 4 avatars (3 avatars + show more) in a column
-    max-height: 120px;
+    --max-size: calc(
+      var(--avatar-size) * var(--max-avatars, 2) + var(--avatarList-gap) *
+        (var(--max-avatars, 2) - 1) + var(--userList-padding) * 2
+    );
 
-    // can fit max 4 avatars (3 avatars + show more) when there's enough space
-    max-width: 120px;
+    // max width & height set to fix the max-avatars
+    max-height: var(--max-size);
+    max-width: var(--max-size);
 
     // Tweak in 30px increments to fit more/fewer avatars in a row/column ^^
-
-    overflow: hidden;
   }
 
   .UserList > * {
@@ -45,10 +55,11 @@
     @include avatarStyles;
     background-color: var(--color-gray-20);
     border: 0 !important;
-    font-size: 0.5rem;
+    font-size: 0.625rem;
     font-weight: 400;
     flex-shrink: 0;
     color: var(--color-gray-100);
+    font-weight: bold;
   }
 
   .UserList__collaborator-name {
@@ -57,11 +68,80 @@
     white-space: nowrap;
   }
 
-  .UserList__collaborator-follow-status-icon {
+  .UserList__collaborator--avatar-only {
+    position: relative;
+    display: flex;
+    flex: 0 0 auto;
+    .UserList__collaborator-status-icon {
+      --size: 14px;
+      position: absolute;
+      display: flex;
+      flex: 0 0 auto;
+      bottom: -0.25rem;
+      right: -0.25rem;
+      width: var(--size);
+      height: var(--size);
+      svg {
+        flex: 0 0 auto;
+        width: var(--size);
+        height: var(--size);
+      }
+    }
+  }
+
+  .UserList__collaborator-status-icons {
     margin-left: auto;
     flex: 0 0 auto;
-    width: 1rem;
+    min-width: 2.25rem;
+    gap: 0.25rem;
+    justify-content: flex-end;
+    display: flex;
+  }
+
+  .UserList__collaborator.is-muted
+    .UserList__collaborator-status-icon-microphone-muted {
+    color: var(--color-danger);
+    filter: drop-shadow(0px 0px 0px rgba(0, 0, 0, 0.5));
+  }
+
+  .UserList__collaborator-status-icon-speaking-indicator {
     display: flex;
+    flex-flow: row nowrap;
+    align-items: center;
+    justify-content: space-between;
+    width: 1rem;
+    padding: 0 3px;
+    box-sizing: border-box;
+
+    div {
+      width: 0.125rem;
+      height: 0.4rem;
+      // keep this in sync with constants.ts
+      background-color: #a2f1a6;
+    }
+
+    div:nth-of-type(1) {
+      animation: speaking-indicator-anim 1s -0.45s ease-in-out infinite;
+    }
+
+    div:nth-of-type(2) {
+      animation: speaking-indicator-anim 1s -0.9s ease-in-out infinite;
+    }
+
+    div:nth-of-type(3) {
+      animation: speaking-indicator-anim 1s -0.15s ease-in-out infinite;
+    }
+  }
+
+  @keyframes speaking-indicator-anim {
+    0%,
+    100% {
+      transform: scaleY(1);
+    }
+
+    50% {
+      transform: scaleY(2);
+    }
   }
 
   --userlist-hint-bg-color: var(--color-gray-10);
@@ -80,7 +160,7 @@
     position: static;
     top: auto;
     margin-top: 0;
-    max-height: 12rem;
+    max-height: 50vh;
     overflow-y: auto;
     padding: 0.25rem 0.5rem;
     border-top: 1px solid var(--userlist-collaborators-border-color);

+ 138 - 92
packages/excalidraw/components/UserList.tsx

@@ -1,6 +1,6 @@
 import "./UserList.scss";
 
-import React from "react";
+import React, { useLayoutEffect } from "react";
 import clsx from "clsx";
 import { Collaborator, SocketId } from "../types";
 import { Tooltip } from "./Tooltip";
@@ -12,9 +12,11 @@ import { Island } from "./Island";
 import { searchIcon } from "./icons";
 import { t } from "../i18n";
 import { isShallowEqual } from "../utils";
+import { supportsResizeObserver } from "../constants";
+import { MarkRequired } from "../utility-types";
 
 export type GoToCollaboratorComponentProps = {
-  clientId: ClientId;
+  socketId: SocketId;
   collaborator: Collaborator;
   withName: boolean;
   isBeingFollowed: boolean;
@@ -23,45 +25,41 @@ export type GoToCollaboratorComponentProps = {
 /** collaborator user id or socket id (fallback) */
 type ClientId = string & { _brand: "UserId" };
 
-const FIRST_N_AVATARS = 3;
+const DEFAULT_MAX_AVATARS = 4;
 const SHOW_COLLABORATORS_FILTER_AT = 8;
 
 const ConditionalTooltipWrapper = ({
   shouldWrap,
   children,
-  clientId,
   username,
 }: {
   shouldWrap: boolean;
   children: React.ReactNode;
   username?: string | null;
-  clientId: ClientId;
 }) =>
   shouldWrap ? (
-    <Tooltip label={username || "Unknown user"} key={clientId}>
-      {children}
-    </Tooltip>
+    <Tooltip label={username || "Unknown user"}>{children}</Tooltip>
   ) : (
-    <React.Fragment key={clientId}>{children}</React.Fragment>
+    <React.Fragment>{children}</React.Fragment>
   );
 
 const renderCollaborator = ({
   actionManager,
   collaborator,
-  clientId,
+  socketId,
   withName = false,
   shouldWrapWithTooltip = false,
   isBeingFollowed,
 }: {
   actionManager: ActionManager;
   collaborator: Collaborator;
-  clientId: ClientId;
+  socketId: SocketId;
   withName?: boolean;
   shouldWrapWithTooltip?: boolean;
   isBeingFollowed: boolean;
 }) => {
   const data: GoToCollaboratorComponentProps = {
-    clientId,
+    socketId,
     collaborator,
     withName,
     isBeingFollowed,
@@ -70,8 +68,7 @@ const renderCollaborator = ({
 
   return (
     <ConditionalTooltipWrapper
-      key={clientId}
-      clientId={clientId}
+      key={socketId}
       username={collaborator.username}
       shouldWrap={shouldWrapWithTooltip}
     >
@@ -82,7 +79,13 @@ const renderCollaborator = ({
 
 type UserListUserObject = Pick<
   Collaborator,
-  "avatarUrl" | "id" | "socketId" | "username"
+  | "avatarUrl"
+  | "id"
+  | "socketId"
+  | "username"
+  | "isInCall"
+  | "isSpeaking"
+  | "isMuted"
 >;
 
 type UserListProps = {
@@ -97,13 +100,19 @@ const collaboratorComparatorKeys = [
   "id",
   "socketId",
   "username",
+  "isInCall",
+  "isSpeaking",
+  "isMuted",
 ] as const;
 
 export const UserList = React.memo(
   ({ className, mobile, collaborators, userToFollow }: UserListProps) => {
     const actionManager = useExcalidrawActionManager();
 
-    const uniqueCollaboratorsMap = new Map<ClientId, Collaborator>();
+    const uniqueCollaboratorsMap = new Map<
+      ClientId,
+      MarkRequired<Collaborator, "socketId">
+    >();
 
     collaborators.forEach((collaborator, socketId) => {
       const userId = (collaborator.id || socketId) as ClientId;
@@ -114,115 +123,147 @@ export const UserList = React.memo(
       );
     });
 
-    const uniqueCollaboratorsArray = Array.from(uniqueCollaboratorsMap).filter(
-      ([_, collaborator]) => collaborator.username?.trim(),
-    );
+    const uniqueCollaboratorsArray = Array.from(
+      uniqueCollaboratorsMap.values(),
+    ).filter((collaborator) => collaborator.username?.trim());
 
     const [searchTerm, setSearchTerm] = React.useState("");
 
-    if (uniqueCollaboratorsArray.length === 0) {
-      return null;
-    }
+    const userListWrapper = React.useRef<HTMLDivElement | null>(null);
+
+    useLayoutEffect(() => {
+      if (userListWrapper.current) {
+        const updateMaxAvatars = (width: number) => {
+          const maxAvatars = Math.max(1, Math.min(8, Math.floor(width / 38)));
+          setMaxAvatars(maxAvatars);
+        };
+
+        updateMaxAvatars(userListWrapper.current.clientWidth);
+
+        if (!supportsResizeObserver) {
+          return;
+        }
+
+        const resizeObserver = new ResizeObserver((entries) => {
+          for (const entry of entries) {
+            const { width } = entry.contentRect;
+            updateMaxAvatars(width);
+          }
+        });
+
+        resizeObserver.observe(userListWrapper.current);
+
+        return () => {
+          resizeObserver.disconnect();
+        };
+      }
+    }, []);
+
+    const [maxAvatars, setMaxAvatars] = React.useState(DEFAULT_MAX_AVATARS);
 
     const searchTermNormalized = searchTerm.trim().toLowerCase();
 
     const filteredCollaborators = searchTermNormalized
-      ? uniqueCollaboratorsArray.filter(([, collaborator]) =>
+      ? uniqueCollaboratorsArray.filter((collaborator) =>
           collaborator.username?.toLowerCase().includes(searchTerm),
         )
       : uniqueCollaboratorsArray;
 
     const firstNCollaborators = uniqueCollaboratorsArray.slice(
       0,
-      FIRST_N_AVATARS,
+      maxAvatars - 1,
     );
 
-    const firstNAvatarsJSX = firstNCollaborators.map(
-      ([clientId, collaborator]) =>
-        renderCollaborator({
-          actionManager,
-          collaborator,
-          clientId,
-          shouldWrapWithTooltip: true,
-          isBeingFollowed: collaborator.socketId === userToFollow,
-        }),
+    const firstNAvatarsJSX = firstNCollaborators.map((collaborator) =>
+      renderCollaborator({
+        actionManager,
+        collaborator,
+        socketId: collaborator.socketId,
+        shouldWrapWithTooltip: true,
+        isBeingFollowed: collaborator.socketId === userToFollow,
+      }),
     );
 
     return mobile ? (
       <div className={clsx("UserList UserList_mobile", className)}>
-        {uniqueCollaboratorsArray.map(([clientId, collaborator]) =>
+        {uniqueCollaboratorsArray.map((collaborator) =>
           renderCollaborator({
             actionManager,
             collaborator,
-            clientId,
+            socketId: collaborator.socketId,
             shouldWrapWithTooltip: true,
             isBeingFollowed: collaborator.socketId === userToFollow,
           }),
         )}
       </div>
     ) : (
-      <div className={clsx("UserList", className)}>
-        {firstNAvatarsJSX}
-
-        {uniqueCollaboratorsArray.length > FIRST_N_AVATARS && (
-          <Popover.Root
-            onOpenChange={(isOpen) => {
-              if (!isOpen) {
-                setSearchTerm("");
-              }
-            }}
-          >
-            <Popover.Trigger className="UserList__more">
-              +{uniqueCollaboratorsArray.length - FIRST_N_AVATARS}
-            </Popover.Trigger>
-            <Popover.Content
-              style={{
-                zIndex: 2,
-                width: "13rem",
-                textAlign: "left",
+      <div className="UserList-wrapper" ref={userListWrapper}>
+        <div
+          className={clsx("UserList", className)}
+          style={{ [`--max-avatars` as any]: maxAvatars }}
+        >
+          {firstNAvatarsJSX}
+
+          {uniqueCollaboratorsArray.length > maxAvatars - 1 && (
+            <Popover.Root
+              onOpenChange={(isOpen) => {
+                if (!isOpen) {
+                  setSearchTerm("");
+                }
               }}
-              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")}
+              <Popover.Trigger className="UserList__more">
+                +{uniqueCollaboratorsArray.length - maxAvatars + 1}
+              </Popover.Trigger>
+              <Popover.Content
+                style={{
+                  zIndex: 2,
+                  width: "15rem",
+                  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="UserList__hint">
-                    {t("userList.hint.text")}
+                  <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((collaborator) =>
+                      renderCollaborator({
+                        actionManager,
+                        collaborator,
+                        socketId: collaborator.socketId,
+                        withName: true,
+                        isBeingFollowed: collaborator.socketId === userToFollow,
+                      }),
+                    )}
                   </div>
-                  {filteredCollaborators.map(([clientId, collaborator]) =>
-                    renderCollaborator({
-                      actionManager,
-                      collaborator,
-                      clientId,
-                      withName: true,
-                      isBeingFollowed: collaborator.socketId === userToFollow,
-                    }),
-                  )}
-                </div>
-              </Island>
-            </Popover.Content>
-          </Popover.Root>
-        )}
+                </Island>
+              </Popover.Content>
+            </Popover.Root>
+          )}
+        </div>
       </div>
     );
   },
@@ -236,10 +277,15 @@ export const UserList = React.memo(
       return false;
     }
 
+    const nextCollaboratorSocketIds = next.collaborators.keys();
+
     for (const [socketId, collaborator] of prev.collaborators) {
       const nextCollaborator = next.collaborators.get(socketId);
       if (
         !nextCollaborator ||
+        // this checks order of collaborators in the map is the same
+        // as previous render
+        socketId !== nextCollaboratorSocketIds.next().value ||
         !isShallowEqual(
           collaborator,
           nextCollaborator,

+ 28 - 24
packages/excalidraw/components/canvases/InteractiveCanvas.tsx

@@ -66,42 +66,46 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
       return;
     }
 
-    const cursorButton: {
-      [id: string]: string | undefined;
-    } = {};
-    const pointerViewportCoords: InteractiveCanvasRenderConfig["remotePointerViewportCoords"] =
-      {};
+    const remotePointerButton: InteractiveCanvasRenderConfig["remotePointerButton"] =
+      new Map();
+    const remotePointerViewportCoords: InteractiveCanvasRenderConfig["remotePointerViewportCoords"] =
+      new Map();
     const remoteSelectedElementIds: InteractiveCanvasRenderConfig["remoteSelectedElementIds"] =
-      {};
-    const pointerUsernames: { [id: string]: string } = {};
-    const pointerUserStates: { [id: string]: string } = {};
+      new Map();
+    const remotePointerUsernames: InteractiveCanvasRenderConfig["remotePointerUsernames"] =
+      new Map();
+    const remotePointerUserStates: InteractiveCanvasRenderConfig["remotePointerUserStates"] =
+      new Map();
 
     props.appState.collaborators.forEach((user, socketId) => {
       if (user.selectedElementIds) {
         for (const id of Object.keys(user.selectedElementIds)) {
-          if (!(id in remoteSelectedElementIds)) {
-            remoteSelectedElementIds[id] = [];
+          if (!remoteSelectedElementIds.has(id)) {
+            remoteSelectedElementIds.set(id, []);
           }
-          remoteSelectedElementIds[id].push(socketId);
+          remoteSelectedElementIds.get(id)!.push(socketId);
         }
       }
       if (!user.pointer) {
         return;
       }
       if (user.username) {
-        pointerUsernames[socketId] = user.username;
+        remotePointerUsernames.set(socketId, user.username);
       }
       if (user.userState) {
-        pointerUserStates[socketId] = user.userState;
+        remotePointerUserStates.set(socketId, user.userState);
       }
-      pointerViewportCoords[socketId] = sceneCoordsToViewportCoords(
-        {
-          sceneX: user.pointer.x,
-          sceneY: user.pointer.y,
-        },
-        props.appState,
+      remotePointerViewportCoords.set(
+        socketId,
+        sceneCoordsToViewportCoords(
+          {
+            sceneX: user.pointer.x,
+            sceneY: user.pointer.y,
+          },
+          props.appState,
+        ),
       );
-      cursorButton[socketId] = user.button;
+      remotePointerButton.set(socketId, user.button);
     });
 
     const selectionColor =
@@ -120,11 +124,11 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
         scale: window.devicePixelRatio,
         appState: props.appState,
         renderConfig: {
-          remotePointerViewportCoords: pointerViewportCoords,
-          remotePointerButton: cursorButton,
+          remotePointerViewportCoords,
+          remotePointerButton,
           remoteSelectedElementIds,
-          remotePointerUsernames: pointerUsernames,
-          remotePointerUserStates: pointerUserStates,
+          remotePointerUsernames,
+          remotePointerUserStates,
           selectionColor,
           renderScrollbars: false,
         },

+ 24 - 1
packages/excalidraw/components/icons.tsx

@@ -1798,7 +1798,7 @@ export const fullscreenIcon = createIcon(
 );
 
 export const eyeIcon = createIcon(
-  <g stroke="currentColor" fill="none">
+  <g stroke="currentColor" fill="none" strokeWidth={1.5}>
     <path stroke="none" d="M0 0h24v24H0z" fill="none" />
     <path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
     <path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
@@ -1837,3 +1837,26 @@ export const searchIcon = createIcon(
   </g>,
   tablerIconProps,
 );
+
+export const microphoneIcon = createIcon(
+  <g strokeWidth={1.5}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M9 2m0 3a3 3 0 0 1 3 -3h0a3 3 0 0 1 3 3v5a3 3 0 0 1 -3 3h0a3 3 0 0 1 -3 -3z" />
+    <path d="M5 10a7 7 0 0 0 14 0" />
+    <path d="M8 21l8 0" />
+    <path d="M12 17l0 4" />
+  </g>,
+  tablerIconProps,
+);
+
+export const microphoneMutedIcon = createIcon(
+  <g strokeWidth={1.5}>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M3 3l18 18" />
+    <path d="M9 5a3 3 0 0 1 6 0v5a3 3 0 0 1 -.13 .874m-2 2a3 3 0 0 1 -3.87 -2.872v-1" />
+    <path d="M5 10a7 7 0 0 0 10.846 5.85m2 -2a6.967 6.967 0 0 0 1.152 -3.85" />
+    <path d="M8 21l8 0" />
+    <path d="M12 17l0 4" />
+  </g>,
+  tablerIconProps,
+);

+ 8 - 0
packages/excalidraw/constants.ts

@@ -20,6 +20,9 @@ export const isIOS =
 export const isBrave = () =>
   (navigator as any).brave?.isBrave?.name === "isBrave";
 
+export const supportsResizeObserver =
+  typeof window !== "undefined" && "ResizeObserver" in window;
+
 export const APP_NAME = "Excalidraw";
 
 export const DRAGGING_THRESHOLD = 10; // px
@@ -144,6 +147,11 @@ export const DEFAULT_VERTICAL_ALIGN = "top";
 export const DEFAULT_VERSION = "{version}";
 export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2;
 
+export const COLOR_WHITE = "#ffffff";
+export const COLOR_CHARCOAL_BLACK = "#1e1e1e";
+// keep this in sync with CSS
+export const COLOR_VOICE_CALL = "#a2f1a6";
+
 export const CANVAS_ONLY_ACTIONS = ["selectAll"];
 
 export const GRID_SIZE = 20; // TODO make it configurable?

+ 9 - 5
packages/excalidraw/css/variables.module.scss

@@ -116,8 +116,8 @@
 }
 
 @mixin avatarStyles {
-  width: 1.25rem;
-  height: 1.25rem;
+  width: var(--avatar-size, 1.5rem);
+  height: var(--avatar-size, 1.5rem);
   position: relative;
   border-radius: 100%;
   outline-offset: 2px;
@@ -131,6 +131,10 @@
   color: var(--color-gray-90);
   flex: 0 0 auto;
 
+  &:active {
+    transform: scale(0.94);
+  }
+
   &-img {
     width: 100%;
     height: 100%;
@@ -144,14 +148,14 @@
     right: -3px;
     bottom: -3px;
     left: -3px;
-    border: 1px solid var(--avatar-border-color);
     border-radius: 100%;
   }
 
-  &--is-followed::before {
+  &.is-followed::before {
     border-color: var(--color-primary-hover);
+    box-shadow: 0 0 0 1px var(--color-primary-hover);
   }
-  &--is-current-user {
+  &.is-current-user {
     cursor: auto;
   }
 }

+ 1 - 1
packages/excalidraw/laser-trails.ts

@@ -84,7 +84,7 @@ export class LaserTrails implements Trail {
       if (!this.collabTrails.has(key)) {
         trail = new AnimatedTrail(this.animationFrameHandler, this.app, {
           ...this.getTrailOptions(),
-          fill: () => getClientColor(key),
+          fill: () => getClientColor(key, collabolator),
         });
         trail.start(this.container);
 

+ 4 - 1
packages/excalidraw/locales/en.json

@@ -534,7 +534,10 @@
     },
     "hint": {
       "text": "Click on user to follow",
-      "followStatus": "You're currently following this user"
+      "followStatus": "You're currently following this user",
+      "inCall": "User is in a voice call",
+      "micMuted": "User's microphone is muted",
+      "isSpeaking": "User is speaking"
     }
   }
 }

+ 21 - 147
packages/excalidraw/renderer/interactiveScene.ts

@@ -15,7 +15,7 @@ import {
 } from "../scene/scrollbars";
 
 import { renderSelectionElement } from "../renderer/renderElement";
-import { getClientColor } from "../clients";
+import { getClientColor, renderRemoteCursors } from "../clients";
 import {
   isSelectedViaGroup,
   getSelectedGroupIds,
@@ -29,7 +29,7 @@ import {
   TransformHandleType,
 } from "../element/transformHandles";
 import { arrayToMap, throttleRAF } from "../utils";
-import { InteractiveCanvasAppState, Point, UserIdleState } from "../types";
+import { InteractiveCanvasAppState, Point } from "../types";
 import { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE } from "../constants";
 
 import { renderSnaps } from "../renderer/renderSnaps";
@@ -726,14 +726,18 @@ const _renderInteractiveScene = ({
           selectionColors.push(selectionColor);
         }
         // remote users
-        if (renderConfig.remoteSelectedElementIds[element.id]) {
+        const remoteClients = renderConfig.remoteSelectedElementIds.get(
+          element.id,
+        );
+        if (remoteClients) {
           selectionColors.push(
-            ...renderConfig.remoteSelectedElementIds[element.id].map(
-              (socketId: string) => {
-                const background = getClientColor(socketId);
-                return background;
-              },
-            ),
+            ...remoteClients.map((socketId) => {
+              const background = getClientColor(
+                socketId,
+                appState.collaborators.get(socketId),
+              );
+              return background;
+            }),
           );
         }
 
@@ -747,7 +751,7 @@ const _renderInteractiveScene = ({
             elementX2,
             elementY2,
             selectionColors,
-            dashed: !!renderConfig.remoteSelectedElementIds[element.id],
+            dashed: !!remoteClients,
             cx,
             cy,
             activeEmbeddable:
@@ -858,143 +862,13 @@ const _renderInteractiveScene = ({
   // Reset zoom
   context.restore();
 
-  // Paint remote pointers
-  for (const clientId in renderConfig.remotePointerViewportCoords) {
-    let { x, y } = renderConfig.remotePointerViewportCoords[clientId];
-
-    x -= appState.offsetLeft;
-    y -= appState.offsetTop;
-
-    const width = 11;
-    const height = 14;
-
-    const isOutOfBounds =
-      x < 0 ||
-      x > normalizedWidth - width ||
-      y < 0 ||
-      y > normalizedHeight - height;
-
-    x = Math.max(x, 0);
-    x = Math.min(x, normalizedWidth - width);
-    y = Math.max(y, 0);
-    y = Math.min(y, normalizedHeight - height);
-
-    const background = getClientColor(clientId);
-
-    context.save();
-    context.strokeStyle = background;
-    context.fillStyle = background;
-
-    const userState = renderConfig.remotePointerUserStates[clientId];
-    const isInactive =
-      isOutOfBounds ||
-      userState === UserIdleState.IDLE ||
-      userState === UserIdleState.AWAY;
-
-    if (isInactive) {
-      context.globalAlpha = 0.3;
-    }
-
-    if (
-      renderConfig.remotePointerButton &&
-      renderConfig.remotePointerButton[clientId] === "down"
-    ) {
-      context.beginPath();
-      context.arc(x, y, 15, 0, 2 * Math.PI, false);
-      context.lineWidth = 3;
-      context.strokeStyle = "#ffffff88";
-      context.stroke();
-      context.closePath();
-
-      context.beginPath();
-      context.arc(x, y, 15, 0, 2 * Math.PI, false);
-      context.lineWidth = 1;
-      context.strokeStyle = background;
-      context.stroke();
-      context.closePath();
-    }
-
-    // Background (white outline) for arrow
-    context.fillStyle = oc.white;
-    context.strokeStyle = oc.white;
-    context.lineWidth = 6;
-    context.lineJoin = "round";
-    context.beginPath();
-    context.moveTo(x, y);
-    context.lineTo(x + 0, y + 14);
-    context.lineTo(x + 4, y + 9);
-    context.lineTo(x + 11, y + 8);
-    context.closePath();
-    context.stroke();
-    context.fill();
-
-    // Arrow
-    context.fillStyle = background;
-    context.strokeStyle = background;
-    context.lineWidth = 2;
-    context.lineJoin = "round";
-    context.beginPath();
-    if (isInactive) {
-      context.moveTo(x - 1, y - 1);
-      context.lineTo(x - 1, y + 15);
-      context.lineTo(x + 5, y + 10);
-      context.lineTo(x + 12, y + 9);
-      context.closePath();
-      context.fill();
-    } else {
-      context.moveTo(x, y);
-      context.lineTo(x + 0, y + 14);
-      context.lineTo(x + 4, y + 9);
-      context.lineTo(x + 11, y + 8);
-      context.closePath();
-      context.fill();
-      context.stroke();
-    }
-
-    const username = renderConfig.remotePointerUsernames[clientId] || "";
-
-    if (!isOutOfBounds && username) {
-      context.font = "600 12px sans-serif"; // font has to be set before context.measureText()
-
-      const offsetX = x + width / 2;
-      const offsetY = y + height + 2;
-      const paddingHorizontal = 5;
-      const paddingVertical = 3;
-      const measure = context.measureText(username);
-      const measureHeight =
-        measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent;
-      const finalHeight = Math.max(measureHeight, 12);
-
-      const boxX = offsetX - 1;
-      const boxY = offsetY - 1;
-      const boxWidth = measure.width + 2 + paddingHorizontal * 2 + 2;
-      const boxHeight = finalHeight + 2 + paddingVertical * 2 + 2;
-      if (context.roundRect) {
-        context.beginPath();
-        context.roundRect(boxX, boxY, boxWidth, boxHeight, 8);
-        context.fillStyle = background;
-        context.fill();
-        context.strokeStyle = oc.white;
-        context.stroke();
-      } else {
-        roundRect(context, boxX, boxY, boxWidth, boxHeight, 8, oc.white);
-      }
-      context.fillStyle = oc.black;
-
-      context.fillText(
-        username,
-        offsetX + paddingHorizontal + 1,
-        offsetY +
-          paddingVertical +
-          measure.actualBoundingBoxAscent +
-          Math.floor((finalHeight - measureHeight) / 2) +
-          2,
-      );
-    }
-
-    context.restore();
-    context.closePath();
-  }
+  renderRemoteCursors({
+    context,
+    renderConfig,
+    appState,
+    normalizedWidth,
+    normalizedHeight,
+  });
 
   // Paint scrollbars
   let scrollBars;

+ 8 - 5
packages/excalidraw/scene/types.ts

@@ -1,6 +1,7 @@
 import type { RoughCanvas } from "roughjs/bin/canvas";
 import { Drawable } from "roughjs/bin/core";
 import {
+  ExcalidrawElement,
   ExcalidrawTextElement,
   NonDeletedElementsMap,
   NonDeletedExcalidrawElement,
@@ -13,6 +14,8 @@ import {
   ElementsPendingErasure,
   InteractiveCanvasAppState,
   StaticCanvasAppState,
+  SocketId,
+  UserIdleState,
 } from "../types";
 import { MakeBrand } from "../utility-types";
 
@@ -46,11 +49,11 @@ export type SVGRenderConfig = {
 export type InteractiveCanvasRenderConfig = {
   // collab-related state
   // ---------------------------------------------------------------------------
-  remoteSelectedElementIds: { [elementId: string]: string[] };
-  remotePointerViewportCoords: { [id: string]: { x: number; y: number } };
-  remotePointerUserStates: { [id: string]: string };
-  remotePointerUsernames: { [id: string]: string };
-  remotePointerButton?: { [id: string]: string | undefined };
+  remoteSelectedElementIds: Map<ExcalidrawElement["id"], SocketId[]>;
+  remotePointerViewportCoords: Map<SocketId, { x: number; y: number }>;
+  remotePointerUserStates: Map<SocketId, UserIdleState>;
+  remotePointerUsernames: Map<SocketId, string>;
+  remotePointerButton: Map<SocketId, string | undefined>;
   selectionColor?: string;
   // extra options passed to the renderer
   // ---------------------------------------------------------------------------

+ 5 - 2
packages/excalidraw/types.ts

@@ -61,6 +61,9 @@ export type Collaborator = Readonly<{
   id?: string;
   socketId?: SocketId;
   isCurrentUser?: boolean;
+  isInCall?: boolean;
+  isSpeaking?: boolean;
+  isMuted?: boolean;
 }>;
 
 export type CollaboratorPointer = {
@@ -319,9 +322,9 @@ export interface AppState {
     y: number;
   } | null;
   objectsSnapModeEnabled: boolean;
-  /** the user's clientId & username who is being followed on the canvas */
+  /** the user's socket id & username who is being followed on the canvas */
   userToFollow: UserToFollow | null;
-  /** the clientIds of the users following the current user */
+  /** the socket ids of the users following the current user */
   followedBy: Set<SocketId>;
 }
 

+ 8 - 0
packages/excalidraw/utils.ts

@@ -791,6 +791,14 @@ export const isShallowEqual = <
   const aKeys = Object.keys(objA);
   const bKeys = Object.keys(objB);
   if (aKeys.length !== bKeys.length) {
+    if (debug) {
+      console.warn(
+        `%cisShallowEqual: objects don't have same properties ->`,
+        "color: #8B4000",
+        objA,
+        objB,
+      );
+    }
     return false;
   }