feat: expose more collaborator status icons (#7777)

This commit is contained in:
David Luzar 2024-03-18 10:20:07 +01:00 committed by GitHub
parent b7babe554b
commit 068895db0e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 652 additions and 335 deletions

View file

@ -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;

View file

@ -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"

View file

@ -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 {

View file

@ -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,13 +68,82 @@
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);
--userlist-hint-heading-color: var(--color-gray-80);
--userlist-hint-text-color: var(--color-gray-60);
@ -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);

View file

@ -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}
<div className="UserList-wrapper" ref={userListWrapper}>
<div
className={clsx("UserList", className)}
style={{ [`--max-avatars` as any]: maxAvatars }}
>
{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",
{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,

View file

@ -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,
},

View file

@ -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,
);