Compare commits

...

17 Commits

Author SHA1 Message Date
barnabasmolnar
6354501cca Finishing up some TODOs + cleanup. 2023-08-09 18:59:22 +02:00
barnabasmolnar
33b78c35ea Added filter to userlist. 2023-08-09 01:12:28 +02:00
barnabasmolnar
fdd3cd5e79 Export zoomToFitBounds from package.
Host apps can use it to help implement their own follow mode.
2023-08-09 00:22:07 +02:00
barnabasmolnar
d7b7a6715e some more cleanup 2023-08-09 00:07:57 +02:00
barnabasmolnar
8062bd1027 small cleanup 2023-08-08 15:12:24 +02:00
barnabasmolnar
ada5ddc675 [WIP] Dropdown => Popover for userlist.
Seems to make more sense with the fixed header and footer elements.
2023-08-08 01:13:01 +02:00
barnabasmolnar
b4867cb3dc Implemented some TODOs. 2023-08-04 17:43:45 +02:00
barnabasmolnar
a576b0e3b5 [WIP] Began refactoring to more efficient impl. 2023-08-04 02:51:28 +02:00
barnabasmolnar
d695b4044d cleanup 2023-08-03 01:07:07 +02:00
barnabasmolnar
22ad63f967 Some cleanup. 2023-08-02 17:31:21 +02:00
barnabasmolnar
bdf0c8c67c [WIP] Refactor collab userlist=>show more dropdown 2023-08-01 23:07:30 +02:00
barnabasmolnar
898564bc2e Wired up follow styling with business logic. 2023-07-31 18:54:11 +02:00
barnabasmolnar
1777b4566c POC: follow mode indicator styling 2023-07-28 15:09:53 +02:00
barnabasmolnar
a9cfd97cc4 disconnect follow mode on canvas interaction 2023-07-27 18:45:06 +02:00
barnabasmolnar
f255a0835f follow scroll + zoom => follow scrollAndZoom 2023-07-25 01:11:05 +02:00
barnabasmolnar
42a90def41 follow zoom POC 2023-07-21 00:57:34 +02:00
barnabasmolnar
9152ce24f2 follow scroll location POC 2023-07-21 00:34:29 +02:00
22 changed files with 892 additions and 115 deletions

View File

@ -108,6 +108,9 @@ export const actionZoomIn = register({
},
appState,
),
userToFollow: appState.shouldDisconnectFollowModeOnCanvasInteraction
? null
: appState.userToFollow,
},
commitToHistory: false,
};
@ -145,6 +148,9 @@ export const actionZoomOut = register({
},
appState,
),
userToFollow: appState.shouldDisconnectFollowModeOnCanvasInteraction
? null
: appState.userToFollow,
},
commitToHistory: false,
};
@ -182,6 +188,9 @@ export const actionResetZoom = register({
},
appState,
),
userToFollow: appState.shouldDisconnectFollowModeOnCanvasInteraction
? null
: appState.userToFollow,
},
commitToHistory: false,
};
@ -225,22 +234,20 @@ const zoomValueToFitBoundsOnViewport = (
return clampedZoomValueToFitElements as NormalizedZoomValue;
};
export const zoomToFit = ({
targetElements,
export const zoomToFitBounds = ({
bounds,
appState,
fitToViewport = false,
viewportZoomFactor = 0.7,
}: {
targetElements: readonly ExcalidrawElement[];
bounds: readonly [number, number, number, number];
appState: Readonly<AppState>;
/** whether to fit content to viewport (beyond >100%) */
fitToViewport: boolean;
/** zoom content to cover X of the viewport, when fitToViewport=true */
viewportZoomFactor?: number;
}) => {
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
const [x1, y1, x2, y2] = commonBounds;
const [x1, y1, x2, y2] = bounds;
const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 2;
@ -267,7 +274,7 @@ export const zoomToFit = ({
scrollX = (appState.width / 2) * (1 / newZoomValue) - centerX;
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
} else {
newZoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
newZoomValue = zoomValueToFitBoundsOnViewport(bounds, {
width: appState.width,
height: appState.height,
});
@ -296,6 +303,29 @@ export const zoomToFit = ({
};
};
export const zoomToFit = ({
targetElements,
appState,
fitToViewport,
viewportZoomFactor,
}: {
targetElements: readonly ExcalidrawElement[];
appState: Readonly<AppState>;
/** whether to fit content to viewport (beyond >100%) */
fitToViewport: boolean;
/** zoom content to cover X of the viewport, when fitToViewport=true */
viewportZoomFactor?: number;
}) => {
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
return zoomToFitBounds({
bounds: commonBounds,
appState,
fitToViewport,
viewportZoomFactor,
});
};
// Note, this action differs from actionZoomToFitSelection in that it doesn't
// zoom beyond 100%. In other words, if the content is smaller than viewport
// size, it won't be zoomed in.
@ -306,7 +336,12 @@ export const actionZoomToFitSelectionInViewport = register({
const selectedElements = app.scene.getSelectedElements(appState);
return zoomToFit({
targetElements: selectedElements.length ? selectedElements : elements,
appState,
appState: {
...appState,
userToFollow: appState.shouldDisconnectFollowModeOnCanvasInteraction
? null
: appState.userToFollow,
},
fitToViewport: false,
});
},
@ -326,7 +361,12 @@ export const actionZoomToFitSelection = register({
const selectedElements = app.scene.getSelectedElements(appState);
return zoomToFit({
targetElements: selectedElements.length ? selectedElements : elements,
appState,
appState: {
...appState,
userToFollow: appState.shouldDisconnectFollowModeOnCanvasInteraction
? null
: appState.userToFollow,
},
fitToViewport: true,
});
},
@ -343,7 +383,16 @@ export const actionZoomToFit = register({
viewMode: true,
trackEvent: { category: "canvas" },
perform: (elements, appState) =>
zoomToFit({ targetElements: elements, appState, fitToViewport: false }),
zoomToFit({
targetElements: elements,
appState: {
...appState,
userToFollow: appState.shouldDisconnectFollowModeOnCanvasInteraction
? null
: appState.userToFollow,
},
fitToViewport: false,
}),
keyTest: (event) =>
event.code === CODES.ONE &&
event.shiftKey &&

View File

@ -9,14 +9,30 @@ export const actionGoToCollaborator = register({
viewMode: true,
trackEvent: { category: "collab" },
perform: (_elements, appState, value) => {
const point = value as Collaborator["pointer"];
const _value = value as Collaborator & { clientId: string };
const point = _value.pointer;
if (!point) {
return { appState, commitToHistory: false };
}
if (appState.userToFollow?.clientId === _value.clientId) {
return {
appState: {
...appState,
userToFollow: null,
},
commitToHistory: false,
};
}
return {
appState: {
...appState,
userToFollow: {
clientId: _value.clientId,
username: _value.username || "",
},
...centerScrollOn({
scenePoint: point,
viewportDimensions: {
@ -31,17 +47,36 @@ export const actionGoToCollaborator = register({
commitToHistory: false,
};
},
PanelComponent: ({ updateData, data }) => {
const [clientId, collaborator] = data as [string, Collaborator];
PanelComponent: ({ updateData, data, appState }) => {
const [clientId, collaborator, withName] = data as [
string,
Collaborator,
boolean,
];
const background = getClientColor(clientId);
return (
return withName ? (
<div
className="dropdown-menu-item dropdown-menu-item-base"
onClick={() => updateData({ ...collaborator, clientId })}
>
<Avatar
color={background}
onClick={() => updateData(collaborator.pointer)}
onClick={() => {}}
name={collaborator.username || ""}
src={collaborator.avatarUrl}
isBeingFollowed={appState.userToFollow?.clientId === clientId}
/>
{collaborator.username}
</div>
) : (
<Avatar
color={background}
onClick={() => updateData({ ...collaborator, clientId })}
name={collaborator.username || ""}
src={collaborator.avatarUrl}
isBeingFollowed={appState.userToFollow?.clientId === clientId}
/>
);
},

View File

@ -98,6 +98,9 @@ export const getDefaultAppState = (): Omit<
pendingImageElementId: null,
showHyperlinkPopup: false,
selectedLinearElement: null,
userToFollow: null,
shouldDisconnectFollowModeOnCanvasInteraction: true,
amIBeingFollowed: false,
};
};
@ -204,6 +207,13 @@ const APP_STATE_STORAGE_CONF = (<
pendingImageElementId: { browser: false, export: false, server: false },
showHyperlinkPopup: { browser: false, export: false, server: false },
selectedLinearElement: { browser: true, export: false, server: false },
userToFollow: { browser: false, export: false, server: false },
shouldDisconnectFollowModeOnCanvasInteraction: {
browser: false,
export: false,
server: false,
},
amIBeingFollowed: { browser: false, export: false, server: false },
});
const _clearAppStateForStorage = <

View File

@ -331,6 +331,7 @@ import { actionWrapTextInContainer } from "../actions/actionBoundText";
import BraveMeasureTextError from "./BraveMeasureTextError";
import { activeEyeDropperAtom } from "./EyeDropper";
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import FollowMode from "./FollowMode/FollowMode";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@ -547,8 +548,19 @@ class App extends React.Component<AppProps, AppState> {
} = this.state;
const canvasWidth = canvasDOMWidth * canvasScale;
const canvasHeight = canvasDOMHeight * canvasScale;
const userToFollow = this.state.userToFollow;
if (viewModeEnabled) {
return (
<FollowMode
width={canvasDOMWidth}
height={canvasDOMHeight}
userToFollow={userToFollow}
onDisconnect={() => {
this.setState({ userToFollow: null });
}}
>
<canvas
className="excalidraw__canvas"
style={{
@ -570,9 +582,18 @@ class App extends React.Component<AppProps, AppState> {
>
{t("labels.drawingCanvas")}
</canvas>
</FollowMode>
);
}
return (
<FollowMode
width={canvasDOMWidth}
height={canvasDOMHeight}
userToFollow={userToFollow}
onDisconnect={() => {
this.setState({ userToFollow: null });
}}
>
<canvas
className="excalidraw__canvas"
style={{
@ -594,6 +615,7 @@ class App extends React.Component<AppProps, AppState> {
>
{t("labels.drawingCanvas")}
</canvas>
</FollowMode>
);
}
@ -1500,13 +1522,37 @@ class App extends React.Component<AppProps, AppState> {
this.refreshDeviceState(this.excalidrawContainerRef.current);
}
if (
const hasScrollChanged =
prevState.scrollX !== this.state.scrollX ||
prevState.scrollY !== this.state.scrollY
) {
prevState.scrollY !== this.state.scrollY;
if (hasScrollChanged) {
this.props?.onScrollChange?.(this.state.scrollX, this.state.scrollY);
}
if (prevState.zoom.value !== this.state.zoom.value || hasScrollChanged) {
this.props?.onScrollAndZoomChange?.({
zoom: this.state.zoom,
scroll: { x: this.state.scrollX, y: this.state.scrollY },
});
}
if (prevState.userToFollow !== this.state.userToFollow) {
if (prevState.userToFollow) {
this.props?.onUserFollowed?.({
userToFollow: prevState.userToFollow,
action: "unfollow",
});
}
if (this.state.userToFollow) {
this.props?.onUserFollowed?.({
userToFollow: this.state.userToFollow,
action: "follow",
});
}
}
if (
Object.keys(this.state.selectedElementIds).length &&
isEraserActive(this.state)
@ -2365,7 +2411,14 @@ class App extends React.Component<AppProps, AppState> {
state,
) => {
this.cancelInProgresAnimation?.();
this.setState(state);
// potentially unfollow participant
this.setState((prevState, props) => ({
...(typeof state === "function" ? state(prevState, props) : state),
userToFollow: prevState.shouldDisconnectFollowModeOnCanvasInteraction
? null
: prevState.userToFollow,
}));
};
setToast = (
@ -3980,6 +4033,12 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasPointerDown = (
event: React.PointerEvent<HTMLElement>,
) => {
this.setState((prevState) => ({
userToFollow: prevState.shouldDisconnectFollowModeOnCanvasInteraction
? null
: prevState.userToFollow,
}));
// 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,50 @@
import { UserToFollow } from "../../types";
import { CloseIcon } from "../icons";
import "./FollowMode.scss";
interface FollowModeProps {
width: number;
height: number;
children: React.ReactNode;
userToFollow?: UserToFollow | null;
onDisconnect: () => void;
}
const FollowMode = ({
children,
height,
width,
userToFollow,
onDisconnect,
}: FollowModeProps) => {
if (!userToFollow) {
return <>{children}</>;
}
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>
{children}
</div>
);
};
export default FollowMode;

View File

@ -22,6 +22,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,104 @@
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-70);
}
--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 {
background-color: var(--userlist-hint-bg-color);
padding: 0.5rem 0.75rem;
overflow: hidden;
border-radius: 0 0 var(--border-radius-lg) var(--border-radius-lg);
&-heading {
color: var(--userlist-hint-heading-color);
font-size: 0.875rem;
font-weight: 600;
line-height: 150%;
}
&-text {
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

@ -5,48 +5,262 @@ import clsx from "clsx";
import { AppState, Collaborator } from "../types";
import { Tooltip } from "./Tooltip";
import { useExcalidrawActionManager } from "./App";
import { ActionManager } from "../actions/manager";
export const UserList: React.FC<{
import * as Popover from "@radix-ui/react-popover";
import { Island } from "./Island";
import { searchIcon } from "./icons";
import { t } from "../i18n";
// TODO follow-participant
// can be used for debugging styling, filter, etc
// don't forget to remove it before shipping
const sampleCollaborators = new Map([
[
"client-id-1",
{
username: "John Doe",
color: "#1CA6FC",
},
],
[
"client-id-2",
{
username: "Jane Doe",
color: "#FEA3AA",
},
],
[
"client-id-3",
{
username: "Kate Doe",
color: "#B2F2BB",
},
],
[
"client-id-4",
{
username: "Handsome Swan",
color: "#FFDBAB",
},
],
[
"client-id-5",
{
username: "Brilliant Chameleon",
color: "#E2E2E2",
},
],
[
"client-id-6",
{
username: "Jill Doe",
color: "#FCCB5F",
},
],
[
"client-id-7",
{
username: "Jack Doe",
color: "#BCE784",
},
],
[
"client-id-8",
{
username: "Jolly Doe",
color: "#5DD39E",
},
],
]) as any as Map<string, Collaborator>;
const FIRST_N_AVATARS = 3;
const SHOW_COLLABORATORS_FILTER_AT = 6;
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>
);
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 (
<ConditionalTooltipWrapper
key={clientId}
clientId={clientId}
username={collaborator.username}
shouldWrap={shouldWrapWithTooltip}
>
{avatarJSX}
</ConditionalTooltipWrapper>
);
};
export const UserList = ({
className,
mobile,
collaborators,
}: {
className?: string;
mobile?: boolean;
collaborators: AppState["collaborators"];
}> = ({ className, mobile, collaborators }) => {
}) => {
const actionManager = useExcalidrawActionManager();
const uniqueCollaborators = new Map<string, Collaborator>();
const uniqueCollaboratorsMap = React.useMemo(() => {
const map = new Map<string, Collaborator>();
collaborators.forEach((collaborator, socketId) => {
uniqueCollaborators.set(
map.set(
// filter on user id, else fall back on unique socketId
collaborator.id || socketId,
collaborator,
);
});
return map;
}, [collaborators]);
const avatars =
uniqueCollaborators.size > 0 &&
Array.from(uniqueCollaborators)
.filter(([_, client]) => Object.keys(client).length !== 0)
.map(([clientId, collaborator]) => {
const avatarJSX = actionManager.renderAction("goToCollaborator", [
clientId,
// const uniqueCollaboratorsMap = sampleCollaborators;
const uniqueCollaboratorsArray = React.useMemo(
() =>
Array.from(uniqueCollaboratorsMap).filter(
([_, collaborator]) => Object.keys(collaborator).length !== 0,
),
[uniqueCollaboratorsMap],
);
const [searchTerm, setSearchTerm] = React.useState("");
const filteredCollaborators = React.useMemo(
() =>
uniqueCollaboratorsArray.filter(([, collaborator]) =>
collaborator.username?.toLowerCase().includes(searchTerm.toLowerCase()),
),
[uniqueCollaboratorsArray, searchTerm],
);
if (uniqueCollaboratorsArray.length === 0) {
return null;
}
const firstNCollaborators = uniqueCollaboratorsArray.slice(
0,
FIRST_N_AVATARS,
);
const firstNAvatarsJSX = firstNCollaborators.map(([clientId, collaborator]) =>
renderCollaborator({
actionManager,
collaborator,
]);
clientId,
shouldWrapWithTooltip: true,
}),
);
return mobile ? (
<Tooltip
label={collaborator.username || "Unknown user"}
key={clientId}
>
{avatarJSX}
</Tooltip>
<div className={clsx("UserList UserList_mobile", className)}>
{uniqueCollaboratorsArray.map(([clientId, collaborator]) =>
renderCollaborator({
actionManager,
collaborator,
clientId,
shouldWrapWithTooltip: true,
}),
)}
</div>
) : (
<React.Fragment key={clientId}>{avatarJSX}</React.Fragment>
);
});
<div className={clsx("UserList", className)}>
{firstNAvatarsJSX}
return (
<div className={clsx("UserList", className, { UserList_mobile: mobile })}>
{avatars}
{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, maxWidth: "14rem", textAlign: "left" }}
align="end"
sideOffset={10}
>
<Island style={{ overflow: "hidden" }}>
{SHOW_COLLABORATORS_FILTER_AT <=
uniqueCollaboratorsArray.length && (
<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>
)}
{filteredCollaborators.map(([clientId, collaborator]) =>
renderCollaborator({
actionManager,
collaborator,
clientId,
withName: true,
}),
)}
</div>
<div className="UserList__hint">
<div className="UserList__hint-heading">
{t("userList.hint.heading")}
</div>
<div className="UserList__hint-text">
{t("userList.hint.text")}
</div>
</div>
</Island>
</Popover.Content>
</Popover.Root>
)}
</div>
);
};

View File

@ -1647,3 +1647,12 @@ export const frameToolIcon = 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,
);

View File

@ -81,6 +81,7 @@
--color-primary-darkest: #4a47b1;
--color-primary-light: #e3e2fe;
--color-primary-light-darker: #d7d5ff;
--color-primary-hover: #5753d0;
--color-gray-10: #f5f5f5;
--color-gray-20: #ebebeb;
@ -193,6 +194,7 @@
--color-primary-darkest: #beb9ff;
--color-primary-light: #4f4d6f;
--color-primary-light-darker: #43415e;
--color-primary-hover: #bbb8ff;
--color-text-warning: var(--color-gray-80);

View File

@ -104,6 +104,42 @@
}
}
@mixin avatarStyles {
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%;
}
&--is-followed::before {
border-color: var(--color-primary-hover);
}
}
$theme-filter: "invert(93%) hue-rotate(180deg)";
$right-sidebar-width: "302px";

View File

@ -1,6 +1,10 @@
import throttle from "lodash.throttle";
import { PureComponent } from "react";
import { ExcalidrawImperativeAPI } from "../../types";
import {
AppState,
ExcalidrawImperativeAPI,
OnUserFollowedPayload,
} from "../../types";
import { ErrorDialog } from "../../components/ErrorDialog";
import { APP_NAME, ENV, EVENT } from "../../constants";
import { ImportedDataState } from "../../data/types";
@ -16,6 +20,7 @@ import { Collaborator, Gesture } from "../../types";
import {
preventUnload,
resolvablePromise,
viewportCoordsToSceneCoords,
withBatchedUpdates,
} from "../../utils";
import {
@ -71,6 +76,7 @@ import { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData";
import { atom, useAtom } from "jotai";
import { appJotaiStore } from "../app-jotai";
import { zoomToFitBounds } from "../../actions/actionCanvas";
export const collabAPIAtom = atom<CollabAPI | null>(null);
export const collabDialogShownAtom = atom(false);
@ -89,6 +95,8 @@ export interface CollabAPI {
/** function so that we can access the latest value from stale callbacks */
isCollaborating: () => boolean;
onPointerUpdate: CollabInstance["onPointerUpdate"];
onScrollAndZoomChange: CollabInstance["onScrollAndZoomChange"];
onUserFollowed: CollabInstance["onUserFollowed"];
startCollaboration: CollabInstance["startCollaboration"];
stopCollaboration: CollabInstance["stopCollaboration"];
syncElements: CollabInstance["syncElements"];
@ -162,6 +170,8 @@ class Collab extends PureComponent<Props, CollabState> {
const collabAPI: CollabAPI = {
isCollaborating: this.isCollaborating,
onPointerUpdate: this.onPointerUpdate,
onScrollAndZoomChange: this.onScrollAndZoomChange,
onUserFollowed: this.onUserFollowed,
startCollaboration: this.startCollaboration,
syncElements: this.syncElements,
fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
@ -513,6 +523,7 @@ class Collab extends PureComponent<Props, CollabState> {
case "MOUSE_LOCATION": {
const { pointer, button, username, selectedElementIds } =
decryptedData.payload;
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
decryptedData.payload.socketId ||
// @ts-ignore legacy, see #2094 (#2097)
@ -530,6 +541,23 @@ class Collab extends PureComponent<Props, CollabState> {
});
break;
}
case "SCROLL_AND_ZOOM": {
const { bounds } = decryptedData.payload;
const _appState = this.excalidrawAPI.getAppState();
const { appState } = zoomToFitBounds({
appState: _appState,
bounds,
fitToViewport: true,
viewportZoomFactor: 1,
});
this.excalidrawAPI.updateScene({ appState });
break;
}
case "IDLE_STATUS": {
const { userState, socketId, username } = decryptedData.payload;
const collaborators = new Map(this.collaborators);
@ -556,6 +584,18 @@ class Collab extends PureComponent<Props, CollabState> {
scenePromise.resolve(sceneData);
});
this.portal.socket.on("broadcast-follow", () => {
this.excalidrawAPI.updateScene({
appState: { amIBeingFollowed: true },
});
});
this.portal.socket.on("broadcast-unfollow", () => {
this.excalidrawAPI.updateScene({
appState: { amIBeingFollowed: false },
});
});
this.initializeIdleDetector();
this.setState({
@ -763,6 +803,46 @@ class Collab extends PureComponent<Props, CollabState> {
CURSOR_SYNC_TIMEOUT,
);
onScrollAndZoomChange = throttle(
(payload: { zoom: AppState["zoom"]; scroll: { x: number; y: number } }) => {
const appState = this.excalidrawAPI.getAppState();
if (appState.amIBeingFollowed) {
const { x: x1, y: y1 } = viewportCoordsToSceneCoords(
{ clientX: 0, clientY: 0 },
{
offsetLeft: appState.offsetLeft,
offsetTop: appState.offsetTop,
scrollX: payload.scroll.x,
scrollY: payload.scroll.y,
zoom: payload.zoom,
},
);
const { x: x2, y: y2 } = viewportCoordsToSceneCoords(
{ clientX: appState.width, clientY: appState.height },
{
offsetLeft: appState.offsetLeft,
offsetTop: appState.offsetTop,
scrollX: payload.scroll.x,
scrollY: payload.scroll.y,
zoom: payload.zoom,
},
);
this.portal.socket &&
this.portal.broadcastScrollAndZoom(
{ bounds: [x1, y1, x2, y2] },
`follow_${this.portal.socket.id}`,
);
}
},
);
onUserFollowed = (payload: OnUserFollowedPayload) => {
this.portal.socket && this.portal.broadcastUserFollowed(payload);
};
onIdleStateChange = (userState: UserIdleState) => {
this.portal.broadcastIdleChange(userState);
};

View File

@ -12,7 +12,7 @@ import {
FILE_UPLOAD_TIMEOUT,
WS_SCENE_EVENT_TYPES,
} from "../app_constants";
import { UserIdleState } from "../../types";
import { OnUserFollowedPayload, UserIdleState } from "../../types";
import { trackEvent } from "../../analytics";
import throttle from "lodash.throttle";
import { newElementWith } from "../../element/mutateElement";
@ -83,6 +83,7 @@ class Portal {
async _broadcastSocketData(
data: SocketUpdateData,
volatile: boolean = false,
roomId?: string,
) {
if (this.isOpen()) {
const json = JSON.stringify(data);
@ -91,7 +92,7 @@ class Portal {
this.socket?.emit(
volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER,
this.roomId,
roomId ?? this.roomId,
encryptedBuffer,
iv,
);
@ -213,12 +214,43 @@ class Portal {
username: this.collab.state.username,
},
};
return this._broadcastSocketData(
data as SocketUpdateData,
true, // volatile
);
}
};
broadcastScrollAndZoom = (
payload: {
bounds: [number, number, number, number];
},
roomId: string,
) => {
if (this.socket?.id) {
const data: SocketUpdateDataSource["SCROLL_AND_ZOOM"] = {
type: "SCROLL_AND_ZOOM",
payload: {
socketId: this.socket.id,
username: this.collab.state.username,
bounds: payload.bounds,
},
};
return this._broadcastSocketData(
data as SocketUpdateData,
true, // volatile
roomId,
);
}
};
broadcastUserFollowed = (payload: OnUserFollowedPayload) => {
if (this.socket?.id) {
this.socket?.emit("on-user-follow", payload);
}
};
}
export default Portal;

View File

@ -113,6 +113,14 @@ export type SocketUpdateDataSource = {
username: string;
};
};
SCROLL_AND_ZOOM: {
type: "SCROLL_AND_ZOOM";
payload: {
socketId: string;
username: string;
bounds: [number, number, number, number];
};
};
IDLE_STATUS: {
type: "IDLE_STATUS";
payload: {

View File

@ -649,6 +649,12 @@ const ExcalidrawWrapper = () => {
})}
>
<Excalidraw
onScrollAndZoomChange={({ zoom, scroll }) => {
collabAPI?.onScrollAndZoomChange({ zoom, scroll });
}}
onUserFollowed={(userToFollow) => {
collabAPI?.onUserFollowed(userToFollow);
}}
ref={excalidrawRefCallback}
onChange={onChange}
initialData={initialStatePromiseRef.current.promise}

View File

@ -480,5 +480,15 @@
"description": "Loading external drawing will <bold>replace your existing content</bold>.<br></br>You can back up your drawing first by using one of the options below."
}
}
},
"userList": {
"search": {
"placeholder": "Quick search",
"empty": "No users found"
},
"hint": {
"heading": "Follow mode",
"text": "You can click on a user to toggle follow mode. In follow mode, their canvas movements will be mirrored on your canvas."
}
}
}

View File

@ -41,6 +41,8 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
onLinkOpen,
onPointerDown,
onScrollChange,
onScrollAndZoomChange,
onUserFollowed,
children,
} = props;
@ -115,6 +117,8 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
onLinkOpen={onLinkOpen}
onPointerDown={onPointerDown}
onScrollChange={onScrollChange}
onScrollAndZoomChange={onScrollAndZoomChange}
onUserFollowed={onUserFollowed}
>
{children}
</App>
@ -249,3 +253,5 @@ export { LiveCollaborationTrigger };
export { DefaultSidebar } from "../../components/DefaultSidebar";
export { normalizeLink } from "../../data/url";
export { zoomToFitBounds } from "../../actions/actionCanvas";

View File

@ -98,6 +98,8 @@ export type LastActiveTool =
export type SidebarName = string;
export type SidebarTabName = string;
export type UserToFollow = { clientId: string; username: string };
export type AppState = {
contextMenu: {
items: ContextMenuItems;
@ -223,6 +225,12 @@ export type AppState = {
pendingImageElementId: ExcalidrawImageElement["id"] | null;
showHyperlinkPopup: false | "info" | "editor";
selectedLinearElement: LinearElementEditor | null;
/** the user's clientId who is being followed on the canvas */
userToFollow: UserToFollow | null;
/** whether follow mode should be disconnected when the non-remote user interacts with the canvas */
shouldDisconnectFollowModeOnCanvasInteraction: boolean;
/** whether the user is being followed on the canvas */
amIBeingFollowed: boolean;
};
export type UIAppState = Omit<
@ -307,6 +315,11 @@ export type ExcalidrawInitialDataState = Merge<
}
>;
export type OnUserFollowedPayload = {
userToFollow: UserToFollow;
action: "follow" | "unfollow";
};
export interface ExcalidrawProps {
onChange?: (
elements: readonly ExcalidrawElement[],
@ -360,6 +373,14 @@ export interface ExcalidrawProps {
pointerDownState: PointerDownState,
) => void;
onScrollChange?: (scrollX: number, scrollY: number) => void;
onScrollAndZoomChange?: ({
zoom,
scroll,
}: {
zoom: Zoom;
scroll: { x: number; y: number };
}) => void;
onUserFollowed?: (payload: OnUserFollowedPayload) => void;
children?: React.ReactNode;
}