feat: expose more collaborator status icons (#7777)

pull/7745/merge
David Luzar 11 months ago committed by GitHub
parent b7babe554b
commit 068895db0e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

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

@ -1,10 +1,15 @@
import { getClientColor } from "../clients"; import { getClientColor } from "../clients";
import { Avatar } from "../components/Avatar"; import { Avatar } from "../components/Avatar";
import { GoToCollaboratorComponentProps } from "../components/UserList"; import { GoToCollaboratorComponentProps } from "../components/UserList";
import { eyeIcon } from "../components/icons"; import {
eyeIcon,
microphoneIcon,
microphoneMutedIcon,
} from "../components/icons";
import { t } from "../i18n"; import { t } from "../i18n";
import { Collaborator } from "../types"; import { Collaborator } from "../types";
import { register } from "./register"; import { register } from "./register";
import clsx from "clsx";
export const actionGoToCollaborator = register({ export const actionGoToCollaborator = register({
name: "goToCollaborator", name: "goToCollaborator",
@ -39,14 +44,45 @@ export const actionGoToCollaborator = register({
}; };
}, },
PanelComponent: ({ updateData, data, appState }) => { PanelComponent: ({ updateData, data, appState }) => {
const { clientId, collaborator, withName, isBeingFollowed } = const { socketId, collaborator, withName, isBeingFollowed } =
data as GoToCollaboratorComponentProps; 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 ? ( return withName ? (
<div <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)} onClick={() => updateData<Collaborator>(collaborator)}
> >
<Avatar <Avatar
@ -54,22 +90,27 @@ export const actionGoToCollaborator = register({
onClick={() => {}} onClick={() => {}}
name={collaborator.username || ""} name={collaborator.username || ""}
src={collaborator.avatarUrl} src={collaborator.avatarUrl}
isBeingFollowed={isBeingFollowed} className={statusClassNames}
isCurrentUser={collaborator.isCurrentUser === true}
/> />
<div className="UserList__collaborator-name"> <div className="UserList__collaborator-name">
{collaborator.username} {collaborator.username}
</div> </div>
<div className="UserList__collaborator-status-icons" aria-hidden>
{isBeingFollowed && (
<div <div
className="UserList__collaborator-follow-status-icon" className="UserList__collaborator-status-icon-is-followed"
style={{ visibility: isBeingFollowed ? "visible" : "hidden" }} title={t("userList.hint.followStatus")}
title={isBeingFollowed ? t("userList.hint.followStatus") : undefined}
aria-hidden
> >
{eyeIcon} {eyeIcon}
</div> </div>
)}
{statusIconJSX}
</div>
</div> </div>
) : ( ) : (
<div
className={`UserList__collaborator UserList__collaborator--avatar-only ${statusClassNames}`}
>
<Avatar <Avatar
color={background} color={background}
onClick={() => { onClick={() => {
@ -77,9 +118,14 @@ export const actionGoToCollaborator = register({
}} }}
name={collaborator.username || ""} name={collaborator.username || ""}
src={collaborator.avatarUrl} src={collaborator.avatarUrl}
isBeingFollowed={isBeingFollowed} className={statusClassNames}
isCurrentUser={collaborator.isCurrentUser === true}
/> />
{statusIconJSX && (
<div className="UserList__collaborator-status-icon">
{statusIconJSX}
</div>
)}
</div>
); );
}, },
}); });

@ -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) { function hashToInteger(id: string) {
let hash = 0; let hash = 0;
if (id.length === 0) { if (id.length === 0) {
@ -11,14 +26,12 @@ function hashToInteger(id: string) {
} }
export const getClientColor = ( export const getClientColor = (
/** socketId: SocketId,
* any uniquely identifying key, such as user id or socket id collaborator: Collaborator | undefined,
*/
id: string,
) => { ) => {
// to get more even distribution in case `id` is not uniformly distributed to // to get more even distribution in case `id` is not uniformly distributed to
// begin with, we hash it // 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 // 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. // words a hue value of step size 10). There are 37 such values including 0.
const hue = (hash % 37) * 10; const hue = (hash % 37) * 10;
@ -38,3 +51,209 @@ export const getNameInitial = (name?: string | null) => {
firstCodePoint ? String.fromCodePoint(firstCodePoint) : "?" firstCodePoint ? String.fromCodePoint(firstCodePoint) : "?"
).toUpperCase(); ).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();
}
};

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

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

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

@ -1,16 +1,25 @@
@import "../css/variables.module"; @import "../css/variables.module";
.excalidraw { .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 { .UserList {
pointer-events: none; pointer-events: none;
/*github corner*/ padding: var(--userList-padding);
padding: var(--space-factor) var(--space-factor) var(--space-factor)
var(--space-factor);
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
align-items: center; align-items: center;
gap: 0.625rem; gap: var(--avatarList-gap);
&:empty { &:empty {
display: none; display: none;
@ -18,15 +27,16 @@
box-sizing: border-box; box-sizing: border-box;
// can fit max 4 avatars (3 avatars + show more) in a column --max-size: calc(
max-height: 120px; 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 & height set to fix the max-avatars
max-width: 120px; max-height: var(--max-size);
max-width: var(--max-size);
// Tweak in 30px increments to fit more/fewer avatars in a row/column ^^ // Tweak in 30px increments to fit more/fewer avatars in a row/column ^^
overflow: hidden;
} }
.UserList > * { .UserList > * {
@ -45,10 +55,11 @@
@include avatarStyles; @include avatarStyles;
background-color: var(--color-gray-20); background-color: var(--color-gray-20);
border: 0 !important; border: 0 !important;
font-size: 0.5rem; font-size: 0.625rem;
font-weight: 400; font-weight: 400;
flex-shrink: 0; flex-shrink: 0;
color: var(--color-gray-100); color: var(--color-gray-100);
font-weight: bold;
} }
.UserList__collaborator-name { .UserList__collaborator-name {
@ -57,13 +68,82 @@
white-space: nowrap; 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; margin-left: auto;
flex: 0 0 auto; flex: 0 0 auto;
width: 1rem; min-width: 2.25rem;
gap: 0.25rem;
justify-content: flex-end;
display: flex; 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); --userlist-hint-bg-color: var(--color-gray-10);
--userlist-hint-heading-color: var(--color-gray-80); --userlist-hint-heading-color: var(--color-gray-80);
--userlist-hint-text-color: var(--color-gray-60); --userlist-hint-text-color: var(--color-gray-60);
@ -80,7 +160,7 @@
position: static; position: static;
top: auto; top: auto;
margin-top: 0; margin-top: 0;
max-height: 12rem; max-height: 50vh;
overflow-y: auto; overflow-y: auto;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
border-top: 1px solid var(--userlist-collaborators-border-color); border-top: 1px solid var(--userlist-collaborators-border-color);

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

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

@ -1798,7 +1798,7 @@ export const fullscreenIcon = createIcon(
); );
export const eyeIcon = 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 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="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" /> <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>, </g>,
tablerIconProps, 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,
);

@ -20,6 +20,9 @@ export const isIOS =
export const isBrave = () => export const isBrave = () =>
(navigator as any).brave?.isBrave?.name === "isBrave"; (navigator as any).brave?.isBrave?.name === "isBrave";
export const supportsResizeObserver =
typeof window !== "undefined" && "ResizeObserver" in window;
export const APP_NAME = "Excalidraw"; export const APP_NAME = "Excalidraw";
export const DRAGGING_THRESHOLD = 10; // px export const DRAGGING_THRESHOLD = 10; // px
@ -144,6 +147,11 @@ export const DEFAULT_VERTICAL_ALIGN = "top";
export const DEFAULT_VERSION = "{version}"; export const DEFAULT_VERSION = "{version}";
export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2; 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 CANVAS_ONLY_ACTIONS = ["selectAll"];
export const GRID_SIZE = 20; // TODO make it configurable? export const GRID_SIZE = 20; // TODO make it configurable?

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

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

@ -534,7 +534,10 @@
}, },
"hint": { "hint": {
"text": "Click on user to follow", "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"
} }
} }
} }

@ -15,7 +15,7 @@ import {
} from "../scene/scrollbars"; } from "../scene/scrollbars";
import { renderSelectionElement } from "../renderer/renderElement"; import { renderSelectionElement } from "../renderer/renderElement";
import { getClientColor } from "../clients"; import { getClientColor, renderRemoteCursors } from "../clients";
import { import {
isSelectedViaGroup, isSelectedViaGroup,
getSelectedGroupIds, getSelectedGroupIds,
@ -29,7 +29,7 @@ import {
TransformHandleType, TransformHandleType,
} from "../element/transformHandles"; } from "../element/transformHandles";
import { arrayToMap, throttleRAF } from "../utils"; 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 { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE } from "../constants";
import { renderSnaps } from "../renderer/renderSnaps"; import { renderSnaps } from "../renderer/renderSnaps";
@ -726,14 +726,18 @@ const _renderInteractiveScene = ({
selectionColors.push(selectionColor); selectionColors.push(selectionColor);
} }
// remote users // remote users
if (renderConfig.remoteSelectedElementIds[element.id]) { const remoteClients = renderConfig.remoteSelectedElementIds.get(
element.id,
);
if (remoteClients) {
selectionColors.push( selectionColors.push(
...renderConfig.remoteSelectedElementIds[element.id].map( ...remoteClients.map((socketId) => {
(socketId: string) => { const background = getClientColor(
const background = getClientColor(socketId); socketId,
appState.collaborators.get(socketId),
);
return background; return background;
}, }),
),
); );
} }
@ -747,7 +751,7 @@ const _renderInteractiveScene = ({
elementX2, elementX2,
elementY2, elementY2,
selectionColors, selectionColors,
dashed: !!renderConfig.remoteSelectedElementIds[element.id], dashed: !!remoteClients,
cx, cx,
cy, cy,
activeEmbeddable: activeEmbeddable:
@ -858,143 +862,13 @@ const _renderInteractiveScene = ({
// Reset zoom // Reset zoom
context.restore(); context.restore();
// Paint remote pointers renderRemoteCursors({
for (const clientId in renderConfig.remotePointerViewportCoords) { context,
let { x, y } = renderConfig.remotePointerViewportCoords[clientId]; renderConfig,
appState,
x -= appState.offsetLeft; normalizedWidth,
y -= appState.offsetTop; normalizedHeight,
});
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();
}
// Paint scrollbars // Paint scrollbars
let scrollBars; let scrollBars;

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

@ -61,6 +61,9 @@ export type Collaborator = Readonly<{
id?: string; id?: string;
socketId?: SocketId; socketId?: SocketId;
isCurrentUser?: boolean; isCurrentUser?: boolean;
isInCall?: boolean;
isSpeaking?: boolean;
isMuted?: boolean;
}>; }>;
export type CollaboratorPointer = { export type CollaboratorPointer = {
@ -319,9 +322,9 @@ export interface AppState {
y: number; y: number;
} | null; } | null;
objectsSnapModeEnabled: boolean; 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; 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>; followedBy: Set<SocketId>;
} }

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

Loading…
Cancel
Save