feat: follow mode (#6848)

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Barnabás Molnár 2023-12-15 00:07:11 +01:00 committed by GitHub
parent 88a2b286c7
commit aad8ab0123
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1039 additions and 138 deletions

View file

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

View file

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

View file

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

View 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;
}
}
}
}

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

View file

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

View file

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

View file

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

View file

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

View file

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