Compare commits
17 Commits
master
...
barnabasmo
Author | SHA1 | Date | |
---|---|---|---|
|
6354501cca | ||
|
33b78c35ea | ||
|
fdd3cd5e79 | ||
|
d7b7a6715e | ||
|
8062bd1027 | ||
|
ada5ddc675 | ||
|
b4867cb3dc | ||
|
a576b0e3b5 | ||
|
d695b4044d | ||
|
22ad63f967 | ||
|
bdf0c8c67c | ||
|
898564bc2e | ||
|
1777b4566c | ||
|
a9cfd97cc4 | ||
|
f255a0835f | ||
|
42a90def41 | ||
|
9152ce24f2 |
@ -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 &&
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
@ -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 = <
|
||||
|
@ -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
|
||||
|
@ -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
src/components/FollowMode/FollowMode.scss
Normal file
59
src/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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
50
src/components/FollowMode/FollowMode.tsx
Normal file
50
src/components/FollowMode/FollowMode.tsx
Normal 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;
|
@ -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 {
|
||||
|
@ -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,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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,
|
||||
);
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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);
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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: {
|
||||
|
@ -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}
|
||||
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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";
|
||||
|
21
src/types.ts
21
src/types.ts
@ -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;
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user