mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: follow mode (#6848)
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
parent
88a2b286c7
commit
aad8ab0123
28 changed files with 1039 additions and 138 deletions
|
@ -244,6 +244,7 @@ import {
|
|||
KeyboardModifiersObject,
|
||||
CollaboratorPointer,
|
||||
ToolType,
|
||||
OnUserFollowedPayload,
|
||||
} from "../types";
|
||||
import {
|
||||
debounce,
|
||||
|
@ -396,6 +397,7 @@ import { COLOR_PALETTE } from "../colors";
|
|||
import { ElementCanvasButton } from "./MagicButton";
|
||||
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
|
||||
import { EditorLocalStorage } from "../data/EditorLocalStorage";
|
||||
import FollowMode from "./FollowMode/FollowMode";
|
||||
|
||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||
|
@ -551,6 +553,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||
event: PointerEvent,
|
||||
]
|
||||
>();
|
||||
onUserFollowEmitter = new Emitter<[payload: OnUserFollowedPayload]>();
|
||||
onScrollChangeEmitter = new Emitter<
|
||||
[scrollX: number, scrollY: number, zoom: AppState["zoom"]]
|
||||
>();
|
||||
|
||||
constructor(props: AppProps) {
|
||||
super(props);
|
||||
|
@ -620,6 +626,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||
onChange: (cb) => this.onChangeEmitter.on(cb),
|
||||
onPointerDown: (cb) => this.onPointerDownEmitter.on(cb),
|
||||
onPointerUp: (cb) => this.onPointerUpEmitter.on(cb),
|
||||
onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb),
|
||||
onUserFollow: (cb) => this.onUserFollowEmitter.on(cb),
|
||||
} as const;
|
||||
if (typeof excalidrawAPI === "function") {
|
||||
excalidrawAPI(api);
|
||||
|
@ -1582,6 +1590,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||
onPointerDown={this.handleCanvasPointerDown}
|
||||
onDoubleClick={this.handleCanvasDoubleClick}
|
||||
/>
|
||||
{this.state.userToFollow && (
|
||||
<FollowMode
|
||||
width={this.state.width}
|
||||
height={this.state.height}
|
||||
userToFollow={this.state.userToFollow}
|
||||
onDisconnect={this.maybeUnfollowRemoteUser}
|
||||
/>
|
||||
)}
|
||||
{this.renderFrameNames()}
|
||||
</ExcalidrawActionManagerContext.Provider>
|
||||
{this.renderEmbeddables()}
|
||||
|
@ -2531,11 +2547,45 @@ class App extends React.Component<AppProps, AppState> {
|
|||
this.refreshEditorBreakpoints();
|
||||
}
|
||||
|
||||
const hasFollowedPersonLeft =
|
||||
prevState.userToFollow &&
|
||||
!this.state.collaborators.has(prevState.userToFollow.socketId);
|
||||
|
||||
if (hasFollowedPersonLeft) {
|
||||
this.maybeUnfollowRemoteUser();
|
||||
}
|
||||
|
||||
if (
|
||||
prevState.zoom.value !== this.state.zoom.value ||
|
||||
prevState.scrollX !== this.state.scrollX ||
|
||||
prevState.scrollY !== this.state.scrollY
|
||||
) {
|
||||
this.props?.onScrollChange?.(this.state.scrollX, this.state.scrollY);
|
||||
this.props?.onScrollChange?.(
|
||||
this.state.scrollX,
|
||||
this.state.scrollY,
|
||||
this.state.zoom,
|
||||
);
|
||||
this.onScrollChangeEmitter.trigger(
|
||||
this.state.scrollX,
|
||||
this.state.scrollY,
|
||||
this.state.zoom,
|
||||
);
|
||||
}
|
||||
|
||||
if (prevState.userToFollow !== this.state.userToFollow) {
|
||||
if (prevState.userToFollow) {
|
||||
this.onUserFollowEmitter.trigger({
|
||||
userToFollow: prevState.userToFollow,
|
||||
action: "UNFOLLOW",
|
||||
});
|
||||
}
|
||||
|
||||
if (this.state.userToFollow) {
|
||||
this.onUserFollowEmitter.trigger({
|
||||
userToFollow: this.state.userToFollow,
|
||||
action: "FOLLOW",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
|
@ -3421,11 +3471,18 @@ class App extends React.Component<AppProps, AppState> {
|
|||
}
|
||||
};
|
||||
|
||||
private maybeUnfollowRemoteUser = () => {
|
||||
if (this.state.userToFollow) {
|
||||
this.setState({ userToFollow: null });
|
||||
}
|
||||
};
|
||||
|
||||
/** use when changing scrollX/scrollY/zoom based on user interaction */
|
||||
private translateCanvas: React.Component<any, AppState>["setState"] = (
|
||||
state,
|
||||
) => {
|
||||
this.cancelInProgresAnimation?.();
|
||||
this.maybeUnfollowRemoteUser();
|
||||
this.setState(state);
|
||||
};
|
||||
|
||||
|
@ -5154,6 +5211,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||
private handleCanvasPointerDown = (
|
||||
event: React.PointerEvent<HTMLElement>,
|
||||
) => {
|
||||
this.maybeUnfollowRemoteUser();
|
||||
|
||||
// since contextMenu options are potentially evaluated on each render,
|
||||
// and an contextMenu action may depend on selection state, we must
|
||||
// close the contextMenu before we update the selection on pointerDown
|
||||
|
|
|
@ -2,34 +2,6 @@
|
|||
|
||||
.excalidraw {
|
||||
.Avatar {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
position: relative;
|
||||
border-radius: 100%;
|
||||
outline-offset: 2px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
|
||||
&-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
right: -3px;
|
||||
bottom: -3px;
|
||||
left: -3px;
|
||||
border: 1px solid var(--avatar-border-color);
|
||||
border-radius: 100%;
|
||||
}
|
||||
@include avatarStyles;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,21 +2,33 @@ import "./Avatar.scss";
|
|||
|
||||
import React, { useState } from "react";
|
||||
import { getNameInitial } from "../clients";
|
||||
import clsx from "clsx";
|
||||
|
||||
type AvatarProps = {
|
||||
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||
color: string;
|
||||
name: string;
|
||||
src?: string;
|
||||
isBeingFollowed?: boolean;
|
||||
};
|
||||
|
||||
export const Avatar = ({ color, onClick, name, src }: AvatarProps) => {
|
||||
export const Avatar = ({
|
||||
color,
|
||||
onClick,
|
||||
name,
|
||||
src,
|
||||
isBeingFollowed,
|
||||
}: AvatarProps) => {
|
||||
const shortName = getNameInitial(name);
|
||||
const [error, setError] = useState(false);
|
||||
const loadImg = !error && src;
|
||||
const style = loadImg ? undefined : { background: color };
|
||||
return (
|
||||
<div className="Avatar" style={style} onClick={onClick}>
|
||||
<div
|
||||
className={clsx("Avatar", { "Avatar--is-followed": isBeingFollowed })}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
>
|
||||
{loadImg ? (
|
||||
<img
|
||||
className="Avatar-img"
|
||||
|
|
59
packages/excalidraw/components/FollowMode/FollowMode.scss
Normal file
59
packages/excalidraw/components/FollowMode/FollowMode.scss
Normal file
|
@ -0,0 +1,59 @@
|
|||
.excalidraw {
|
||||
.follow-mode {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
border: 2px solid var(--color-primary-hover);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
|
||||
&__badge {
|
||||
background-color: var(--color-primary-hover);
|
||||
color: var(--color-primary-light);
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
pointer-events: all;
|
||||
font-size: 0.75rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
|
||||
&__label {
|
||||
display: flex;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__username {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
&__disconnect-btn {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primary-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--color-primary-darkest);
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
43
packages/excalidraw/components/FollowMode/FollowMode.tsx
Normal file
43
packages/excalidraw/components/FollowMode/FollowMode.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { UserToFollow } from "../../types";
|
||||
import { CloseIcon } from "../icons";
|
||||
import "./FollowMode.scss";
|
||||
|
||||
interface FollowModeProps {
|
||||
width: number;
|
||||
height: number;
|
||||
userToFollow: UserToFollow;
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
const FollowMode = ({
|
||||
height,
|
||||
width,
|
||||
userToFollow,
|
||||
onDisconnect,
|
||||
}: FollowModeProps) => {
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<div className="follow-mode" style={{ width, height }}>
|
||||
<div className="follow-mode__badge">
|
||||
<div className="follow-mode__badge__label">
|
||||
Following{" "}
|
||||
<span
|
||||
className="follow-mode__badge__username"
|
||||
title={userToFollow.username}
|
||||
>
|
||||
{userToFollow.username}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onDisconnect}
|
||||
className="follow-mode__disconnect-btn"
|
||||
>
|
||||
{CloseIcon}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FollowMode;
|
|
@ -21,6 +21,10 @@
|
|||
width: var(--lg-icon-size);
|
||||
height: var(--lg-icon-size);
|
||||
}
|
||||
|
||||
&__label-element {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.default-sidebar-trigger .sidebar-trigger__label {
|
||||
|
|
|
@ -19,7 +19,7 @@ export const SidebarTrigger = ({
|
|||
const appState = useUIAppState();
|
||||
|
||||
return (
|
||||
<label title={title}>
|
||||
<label title={title} className="sidebar-trigger__label-element">
|
||||
<input
|
||||
className="ToolIcon_type_checkbox"
|
||||
type="checkbox"
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.UserList {
|
||||
pointer-events: none;
|
||||
|
@ -14,11 +16,13 @@
|
|||
display: none;
|
||||
}
|
||||
|
||||
// can fit max 5 avatars in a column
|
||||
max-height: 140px;
|
||||
box-sizing: border-box;
|
||||
|
||||
// can fit max 10 avatars in a row when there's enough space
|
||||
max-width: 290px;
|
||||
// can fit max 4 avatars (3 avatars + show more) in a column
|
||||
max-height: 120px;
|
||||
|
||||
// can fit max 4 avatars (3 avatars + show more) when there's enough space
|
||||
max-width: 120px;
|
||||
|
||||
// Tweak in 30px increments to fit more/fewer avatars in a row/column ^^
|
||||
|
||||
|
@ -33,5 +37,93 @@
|
|||
padding: 0;
|
||||
justify-content: normal;
|
||||
margin: 0.5rem 0;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.UserList__more {
|
||||
@include avatarStyles;
|
||||
background-color: var(--color-gray-20);
|
||||
border: 0 !important;
|
||||
font-size: 0.5rem;
|
||||
font-weight: 400;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-gray-100);
|
||||
}
|
||||
|
||||
--userlist-hint-bg-color: var(--color-gray-10);
|
||||
--userlist-hint-heading-color: var(--color-gray-80);
|
||||
--userlist-hint-text-color: var(--color-gray-60);
|
||||
--userlist-collaborators-border-color: var(--color-gray-20);
|
||||
|
||||
&.theme--dark {
|
||||
--userlist-hint-bg-color: var(--color-gray-90);
|
||||
--userlist-hint-heading-color: var(--color-gray-30);
|
||||
--userlist-hint-text-color: var(--color-gray-40);
|
||||
--userlist-collaborators-border-color: var(--color-gray-80);
|
||||
}
|
||||
|
||||
.UserList__collaborators {
|
||||
position: static;
|
||||
top: auto;
|
||||
margin-top: 0;
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-top: 1px solid var(--userlist-collaborators-border-color);
|
||||
border-bottom: 1px solid var(--userlist-collaborators-border-color);
|
||||
|
||||
&__empty {
|
||||
color: var(--color-gray-60);
|
||||
font-size: 0.75rem;
|
||||
line-height: 150%;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.UserList__hint {
|
||||
padding: 0.5rem 0.75rem;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
color: var(--userlist-hint-text-color);
|
||||
font-size: 0.75rem;
|
||||
line-height: 150%;
|
||||
}
|
||||
|
||||
.UserList__search-wrapper {
|
||||
position: relative;
|
||||
height: 2.5rem;
|
||||
|
||||
svg {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
left: 0.75rem;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: var(--color-gray-40);
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.UserList__search {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
font-size: 0.875rem;
|
||||
padding-left: 2.5rem !important;
|
||||
padding-right: 0.75rem !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-gray-40);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,51 +2,234 @@ import "./UserList.scss";
|
|||
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { AppState, Collaborator } from "../types";
|
||||
import { Collaborator, SocketId } from "../types";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { useExcalidrawActionManager } from "./App";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
|
||||
export const UserList: React.FC<{
|
||||
className?: string;
|
||||
mobile?: boolean;
|
||||
collaborators: AppState["collaborators"];
|
||||
}> = ({ className, mobile, collaborators }) => {
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { Island } from "./Island";
|
||||
import { searchIcon } from "./icons";
|
||||
import { t } from "../i18n";
|
||||
import { isShallowEqual } from "../utils";
|
||||
|
||||
const uniqueCollaborators = new Map<string, Collaborator>();
|
||||
collaborators.forEach((collaborator, socketId) => {
|
||||
uniqueCollaborators.set(
|
||||
// filter on user id, else fall back on unique socketId
|
||||
collaborator.id || socketId,
|
||||
collaborator,
|
||||
);
|
||||
});
|
||||
const FIRST_N_AVATARS = 3;
|
||||
const SHOW_COLLABORATORS_FILTER_AT = 8;
|
||||
|
||||
const avatars =
|
||||
uniqueCollaborators.size > 0 &&
|
||||
Array.from(uniqueCollaborators)
|
||||
.filter(([_, client]) => Object.keys(client).length !== 0)
|
||||
.map(([clientId, collaborator]) => {
|
||||
const avatarJSX = actionManager.renderAction("goToCollaborator", [
|
||||
clientId,
|
||||
collaborator,
|
||||
]);
|
||||
const ConditionalTooltipWrapper = ({
|
||||
shouldWrap,
|
||||
children,
|
||||
clientId,
|
||||
username,
|
||||
}: {
|
||||
shouldWrap: boolean;
|
||||
children: React.ReactNode;
|
||||
username?: string | null;
|
||||
clientId: string;
|
||||
}) =>
|
||||
shouldWrap ? (
|
||||
<Tooltip label={username || "Unknown user"} key={clientId}>
|
||||
{children}
|
||||
</Tooltip>
|
||||
) : (
|
||||
<React.Fragment key={clientId}>{children}</React.Fragment>
|
||||
);
|
||||
|
||||
return mobile ? (
|
||||
<Tooltip
|
||||
label={collaborator.username || "Unknown user"}
|
||||
key={clientId}
|
||||
>
|
||||
{avatarJSX}
|
||||
</Tooltip>
|
||||
) : (
|
||||
<React.Fragment key={clientId}>{avatarJSX}</React.Fragment>
|
||||
);
|
||||
});
|
||||
const renderCollaborator = ({
|
||||
actionManager,
|
||||
collaborator,
|
||||
clientId,
|
||||
withName = false,
|
||||
shouldWrapWithTooltip = false,
|
||||
}: {
|
||||
actionManager: ActionManager;
|
||||
collaborator: Collaborator;
|
||||
clientId: string;
|
||||
withName?: boolean;
|
||||
shouldWrapWithTooltip?: boolean;
|
||||
}) => {
|
||||
const avatarJSX = actionManager.renderAction("goToCollaborator", [
|
||||
clientId,
|
||||
collaborator,
|
||||
withName,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={clsx("UserList", className, { UserList_mobile: mobile })}>
|
||||
{avatars}
|
||||
</div>
|
||||
<ConditionalTooltipWrapper
|
||||
key={clientId}
|
||||
clientId={clientId}
|
||||
username={collaborator.username}
|
||||
shouldWrap={shouldWrapWithTooltip}
|
||||
>
|
||||
{avatarJSX}
|
||||
</ConditionalTooltipWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
type UserListUserObject = Pick<
|
||||
Collaborator,
|
||||
"avatarUrl" | "id" | "socketId" | "username"
|
||||
>;
|
||||
|
||||
type UserListProps = {
|
||||
className?: string;
|
||||
mobile?: boolean;
|
||||
collaborators: Map<SocketId, UserListUserObject>;
|
||||
};
|
||||
|
||||
const collaboratorComparatorKeys = [
|
||||
"avatarUrl",
|
||||
"id",
|
||||
"socketId",
|
||||
"username",
|
||||
] as const;
|
||||
|
||||
export const UserList = React.memo(
|
||||
({ className, mobile, collaborators }: UserListProps) => {
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
const uniqueCollaboratorsMap = new Map<string, Collaborator>();
|
||||
|
||||
collaborators.forEach((collaborator, socketId) => {
|
||||
uniqueCollaboratorsMap.set(
|
||||
// filter on user id, else fall back on unique socketId
|
||||
collaborator.id || socketId,
|
||||
{ ...collaborator, socketId },
|
||||
);
|
||||
});
|
||||
|
||||
// const uniqueCollaboratorsMap = sampleCollaborators;
|
||||
const uniqueCollaboratorsArray = Array.from(uniqueCollaboratorsMap).filter(
|
||||
([_, collaborator]) => Object.keys(collaborator).length !== 1,
|
||||
);
|
||||
|
||||
const [searchTerm, setSearchTerm] = React.useState("");
|
||||
|
||||
if (uniqueCollaboratorsArray.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const searchTermNormalized = searchTerm.trim().toLowerCase();
|
||||
|
||||
const filteredCollaborators = searchTermNormalized
|
||||
? uniqueCollaboratorsArray.filter(([, collaborator]) =>
|
||||
collaborator.username?.toLowerCase().includes(searchTerm),
|
||||
)
|
||||
: uniqueCollaboratorsArray;
|
||||
|
||||
const firstNCollaborators = uniqueCollaboratorsArray.slice(
|
||||
0,
|
||||
FIRST_N_AVATARS,
|
||||
);
|
||||
|
||||
const firstNAvatarsJSX = firstNCollaborators.map(
|
||||
([clientId, collaborator]) =>
|
||||
renderCollaborator({
|
||||
actionManager,
|
||||
collaborator,
|
||||
clientId,
|
||||
shouldWrapWithTooltip: true,
|
||||
}),
|
||||
);
|
||||
|
||||
return mobile ? (
|
||||
<div className={clsx("UserList UserList_mobile", className)}>
|
||||
{uniqueCollaboratorsArray.map(([clientId, collaborator]) =>
|
||||
renderCollaborator({
|
||||
actionManager,
|
||||
collaborator,
|
||||
clientId,
|
||||
shouldWrapWithTooltip: true,
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={clsx("UserList", className)}>
|
||||
{firstNAvatarsJSX}
|
||||
|
||||
{uniqueCollaboratorsArray.length > FIRST_N_AVATARS && (
|
||||
<Popover.Root
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setSearchTerm("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Popover.Trigger className="UserList__more">
|
||||
+{uniqueCollaboratorsArray.length - FIRST_N_AVATARS}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
style={{
|
||||
zIndex: 2,
|
||||
width: "12rem",
|
||||
textAlign: "left",
|
||||
}}
|
||||
align="end"
|
||||
sideOffset={10}
|
||||
>
|
||||
<Island style={{ overflow: "hidden" }}>
|
||||
{uniqueCollaboratorsArray.length >=
|
||||
SHOW_COLLABORATORS_FILTER_AT && (
|
||||
<div className="UserList__search-wrapper">
|
||||
{searchIcon}
|
||||
<input
|
||||
className="UserList__search"
|
||||
type="text"
|
||||
placeholder={t("userList.search.placeholder")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="dropdown-menu UserList__collaborators">
|
||||
{filteredCollaborators.length === 0 && (
|
||||
<div className="UserList__collaborators__empty">
|
||||
{t("userList.search.empty")}
|
||||
</div>
|
||||
)}
|
||||
<div className="UserList__hint">
|
||||
{t("userList.hint.text")}
|
||||
</div>
|
||||
{filteredCollaborators.map(([clientId, collaborator]) =>
|
||||
renderCollaborator({
|
||||
actionManager,
|
||||
collaborator,
|
||||
clientId,
|
||||
withName: true,
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
</Island>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prev, next) => {
|
||||
if (
|
||||
prev.collaborators.size !== next.collaborators.size ||
|
||||
prev.mobile !== next.mobile ||
|
||||
prev.className !== next.className
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [socketId, collaborator] of prev.collaborators) {
|
||||
const nextCollaborator = next.collaborators.get(socketId);
|
||||
if (
|
||||
!nextCollaborator ||
|
||||
!isShallowEqual(
|
||||
collaborator,
|
||||
nextCollaborator,
|
||||
collaboratorComparatorKeys,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
|
|
@ -1823,3 +1823,12 @@ export const brainIcon = createIcon(
|
|||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const searchIcon = createIcon(
|
||||
<g strokeWidth={1.5}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0" />
|
||||
<path d="M21 21l-6 -6" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue