mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-04-14 16:40:58 -04:00
feat: expose more collaborator status icons (#7777)
This commit is contained in:
parent
b7babe554b
commit
068895db0e
18 changed files with 652 additions and 335 deletions
|
@ -8,7 +8,7 @@
|
||||||
.top-right-ui {
|
.top-right-ui {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-center {
|
.footer-center {
|
||||||
|
|
|
@ -1,10 +1,15 @@
|
||||||
import { getClientColor } from "../clients";
|
import { getClientColor } from "../clients";
|
||||||
import { Avatar } from "../components/Avatar";
|
import { Avatar } from "../components/Avatar";
|
||||||
import { GoToCollaboratorComponentProps } from "../components/UserList";
|
import { GoToCollaboratorComponentProps } from "../components/UserList";
|
||||||
import { eyeIcon } from "../components/icons";
|
import {
|
||||||
|
eyeIcon,
|
||||||
|
microphoneIcon,
|
||||||
|
microphoneMutedIcon,
|
||||||
|
} from "../components/icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { Collaborator } from "../types";
|
import { Collaborator } from "../types";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
export const actionGoToCollaborator = register({
|
export const actionGoToCollaborator = register({
|
||||||
name: "goToCollaborator",
|
name: "goToCollaborator",
|
||||||
|
@ -39,14 +44,45 @@ export const actionGoToCollaborator = register({
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData, data, appState }) => {
|
PanelComponent: ({ updateData, data, appState }) => {
|
||||||
const { clientId, collaborator, withName, isBeingFollowed } =
|
const { socketId, collaborator, withName, isBeingFollowed } =
|
||||||
data as GoToCollaboratorComponentProps;
|
data as GoToCollaboratorComponentProps;
|
||||||
|
|
||||||
const background = getClientColor(clientId);
|
const background = getClientColor(socketId, collaborator);
|
||||||
|
|
||||||
|
const statusClassNames = clsx({
|
||||||
|
"is-followed": isBeingFollowed,
|
||||||
|
"is-current-user": collaborator.isCurrentUser === true,
|
||||||
|
"is-speaking": collaborator.isSpeaking,
|
||||||
|
"is-in-call": collaborator.isInCall,
|
||||||
|
"is-muted": collaborator.isMuted,
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusIconJSX = collaborator.isInCall ? (
|
||||||
|
collaborator.isSpeaking ? (
|
||||||
|
<div
|
||||||
|
className="UserList__collaborator-status-icon-speaking-indicator"
|
||||||
|
title={t("userList.hint.isSpeaking")}
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
<div />
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
) : collaborator.isMuted ? (
|
||||||
|
<div
|
||||||
|
className="UserList__collaborator-status-icon-microphone-muted"
|
||||||
|
title={t("userList.hint.micMuted")}
|
||||||
|
>
|
||||||
|
{microphoneMutedIcon}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div title={t("userList.hint.inCall")}>{microphoneIcon}</div>
|
||||||
|
)
|
||||||
|
) : null;
|
||||||
|
|
||||||
return withName ? (
|
return withName ? (
|
||||||
<div
|
<div
|
||||||
className="dropdown-menu-item dropdown-menu-item-base UserList__collaborator"
|
className={`dropdown-menu-item dropdown-menu-item-base UserList__collaborator ${statusClassNames}`}
|
||||||
|
style={{ [`--avatar-size` as any]: "1.5rem" }}
|
||||||
onClick={() => updateData<Collaborator>(collaborator)}
|
onClick={() => updateData<Collaborator>(collaborator)}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
|
@ -54,22 +90,27 @@ export const actionGoToCollaborator = register({
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
name={collaborator.username || ""}
|
name={collaborator.username || ""}
|
||||||
src={collaborator.avatarUrl}
|
src={collaborator.avatarUrl}
|
||||||
isBeingFollowed={isBeingFollowed}
|
className={statusClassNames}
|
||||||
isCurrentUser={collaborator.isCurrentUser === true}
|
|
||||||
/>
|
/>
|
||||||
<div className="UserList__collaborator-name">
|
<div className="UserList__collaborator-name">
|
||||||
{collaborator.username}
|
{collaborator.username}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="UserList__collaborator-status-icons" aria-hidden>
|
||||||
|
{isBeingFollowed && (
|
||||||
<div
|
<div
|
||||||
className="UserList__collaborator-follow-status-icon"
|
className="UserList__collaborator-status-icon-is-followed"
|
||||||
style={{ visibility: isBeingFollowed ? "visible" : "hidden" }}
|
title={t("userList.hint.followStatus")}
|
||||||
title={isBeingFollowed ? t("userList.hint.followStatus") : undefined}
|
|
||||||
aria-hidden
|
|
||||||
>
|
>
|
||||||
{eyeIcon}
|
{eyeIcon}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{statusIconJSX}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<div
|
||||||
|
className={`UserList__collaborator UserList__collaborator--avatar-only ${statusClassNames}`}
|
||||||
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
color={background}
|
color={background}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
@ -77,9 +118,14 @@ export const actionGoToCollaborator = register({
|
||||||
}}
|
}}
|
||||||
name={collaborator.username || ""}
|
name={collaborator.username || ""}
|
||||||
src={collaborator.avatarUrl}
|
src={collaborator.avatarUrl}
|
||||||
isBeingFollowed={isBeingFollowed}
|
className={statusClassNames}
|
||||||
isCurrentUser={collaborator.isCurrentUser === true}
|
|
||||||
/>
|
/>
|
||||||
|
{statusIconJSX && (
|
||||||
|
<div className="UserList__collaborator-status-icon">
|
||||||
|
{statusIconJSX}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,3 +1,18 @@
|
||||||
|
import {
|
||||||
|
COLOR_CHARCOAL_BLACK,
|
||||||
|
COLOR_VOICE_CALL,
|
||||||
|
COLOR_WHITE,
|
||||||
|
THEME,
|
||||||
|
} from "./constants";
|
||||||
|
import { roundRect } from "./renderer/roundRect";
|
||||||
|
import { InteractiveCanvasRenderConfig } from "./scene/types";
|
||||||
|
import {
|
||||||
|
Collaborator,
|
||||||
|
InteractiveCanvasAppState,
|
||||||
|
SocketId,
|
||||||
|
UserIdleState,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
function hashToInteger(id: string) {
|
function hashToInteger(id: string) {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
if (id.length === 0) {
|
if (id.length === 0) {
|
||||||
|
@ -11,14 +26,12 @@ function hashToInteger(id: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getClientColor = (
|
export const getClientColor = (
|
||||||
/**
|
socketId: SocketId,
|
||||||
* any uniquely identifying key, such as user id or socket id
|
collaborator: Collaborator | undefined,
|
||||||
*/
|
|
||||||
id: string,
|
|
||||||
) => {
|
) => {
|
||||||
// to get more even distribution in case `id` is not uniformly distributed to
|
// to get more even distribution in case `id` is not uniformly distributed to
|
||||||
// begin with, we hash it
|
// begin with, we hash it
|
||||||
const hash = Math.abs(hashToInteger(id));
|
const hash = Math.abs(hashToInteger(collaborator?.id || socketId));
|
||||||
// we want to get a multiple of 10 number in the range of 0-360 (in other
|
// we want to get a multiple of 10 number in the range of 0-360 (in other
|
||||||
// words a hue value of step size 10). There are 37 such values including 0.
|
// words a hue value of step size 10). There are 37 such values including 0.
|
||||||
const hue = (hash % 37) * 10;
|
const hue = (hash % 37) * 10;
|
||||||
|
@ -38,3 +51,209 @@ export const getNameInitial = (name?: string | null) => {
|
||||||
firstCodePoint ? String.fromCodePoint(firstCodePoint) : "?"
|
firstCodePoint ? String.fromCodePoint(firstCodePoint) : "?"
|
||||||
).toUpperCase();
|
).toUpperCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const renderRemoteCursors = ({
|
||||||
|
context,
|
||||||
|
renderConfig,
|
||||||
|
appState,
|
||||||
|
normalizedWidth,
|
||||||
|
normalizedHeight,
|
||||||
|
}: {
|
||||||
|
context: CanvasRenderingContext2D;
|
||||||
|
renderConfig: InteractiveCanvasRenderConfig;
|
||||||
|
appState: InteractiveCanvasAppState;
|
||||||
|
normalizedWidth: number;
|
||||||
|
normalizedHeight: number;
|
||||||
|
}) => {
|
||||||
|
// Paint remote pointers
|
||||||
|
for (const [socketId, pointer] of renderConfig.remotePointerViewportCoords) {
|
||||||
|
let { x, y } = pointer;
|
||||||
|
|
||||||
|
const collaborator = appState.collaborators.get(socketId);
|
||||||
|
|
||||||
|
x -= appState.offsetLeft;
|
||||||
|
y -= appState.offsetTop;
|
||||||
|
|
||||||
|
const width = 11;
|
||||||
|
const height = 14;
|
||||||
|
|
||||||
|
const isOutOfBounds =
|
||||||
|
x < 0 ||
|
||||||
|
x > normalizedWidth - width ||
|
||||||
|
y < 0 ||
|
||||||
|
y > normalizedHeight - height;
|
||||||
|
|
||||||
|
x = Math.max(x, 0);
|
||||||
|
x = Math.min(x, normalizedWidth - width);
|
||||||
|
y = Math.max(y, 0);
|
||||||
|
y = Math.min(y, normalizedHeight - height);
|
||||||
|
|
||||||
|
const background = getClientColor(socketId, collaborator);
|
||||||
|
|
||||||
|
context.save();
|
||||||
|
context.strokeStyle = background;
|
||||||
|
context.fillStyle = background;
|
||||||
|
|
||||||
|
const userState = renderConfig.remotePointerUserStates.get(socketId);
|
||||||
|
const isInactive =
|
||||||
|
isOutOfBounds ||
|
||||||
|
userState === UserIdleState.IDLE ||
|
||||||
|
userState === UserIdleState.AWAY;
|
||||||
|
|
||||||
|
if (isInactive) {
|
||||||
|
context.globalAlpha = 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renderConfig.remotePointerButton.get(socketId) === "down") {
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(x, y, 15, 0, 2 * Math.PI, false);
|
||||||
|
context.lineWidth = 3;
|
||||||
|
context.strokeStyle = "#ffffff88";
|
||||||
|
context.stroke();
|
||||||
|
context.closePath();
|
||||||
|
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(x, y, 15, 0, 2 * Math.PI, false);
|
||||||
|
context.lineWidth = 1;
|
||||||
|
context.strokeStyle = background;
|
||||||
|
context.stroke();
|
||||||
|
context.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO remove the dark theme color after we stop inverting canvas colors
|
||||||
|
const IS_SPEAKING_COLOR =
|
||||||
|
appState.theme === THEME.DARK ? "#2f6330" : COLOR_VOICE_CALL;
|
||||||
|
|
||||||
|
const isSpeaking = collaborator?.isSpeaking;
|
||||||
|
|
||||||
|
if (isSpeaking) {
|
||||||
|
// cursor outline for currently speaking user
|
||||||
|
context.fillStyle = IS_SPEAKING_COLOR;
|
||||||
|
context.strokeStyle = IS_SPEAKING_COLOR;
|
||||||
|
context.lineWidth = 10;
|
||||||
|
context.lineJoin = "round";
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(x, y);
|
||||||
|
context.lineTo(x + 0, y + 14);
|
||||||
|
context.lineTo(x + 4, y + 9);
|
||||||
|
context.lineTo(x + 11, y + 8);
|
||||||
|
context.closePath();
|
||||||
|
context.stroke();
|
||||||
|
context.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background (white outline) for arrow
|
||||||
|
context.fillStyle = COLOR_WHITE;
|
||||||
|
context.strokeStyle = COLOR_WHITE;
|
||||||
|
context.lineWidth = 6;
|
||||||
|
context.lineJoin = "round";
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(x, y);
|
||||||
|
context.lineTo(x + 0, y + 14);
|
||||||
|
context.lineTo(x + 4, y + 9);
|
||||||
|
context.lineTo(x + 11, y + 8);
|
||||||
|
context.closePath();
|
||||||
|
context.stroke();
|
||||||
|
context.fill();
|
||||||
|
|
||||||
|
// Arrow
|
||||||
|
context.fillStyle = background;
|
||||||
|
context.strokeStyle = background;
|
||||||
|
context.lineWidth = 2;
|
||||||
|
context.lineJoin = "round";
|
||||||
|
context.beginPath();
|
||||||
|
if (isInactive) {
|
||||||
|
context.moveTo(x - 1, y - 1);
|
||||||
|
context.lineTo(x - 1, y + 15);
|
||||||
|
context.lineTo(x + 5, y + 10);
|
||||||
|
context.lineTo(x + 12, y + 9);
|
||||||
|
context.closePath();
|
||||||
|
context.fill();
|
||||||
|
} else {
|
||||||
|
context.moveTo(x, y);
|
||||||
|
context.lineTo(x + 0, y + 14);
|
||||||
|
context.lineTo(x + 4, y + 9);
|
||||||
|
context.lineTo(x + 11, y + 8);
|
||||||
|
context.closePath();
|
||||||
|
context.fill();
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = renderConfig.remotePointerUsernames.get(socketId) || "";
|
||||||
|
|
||||||
|
if (!isOutOfBounds && username) {
|
||||||
|
context.font = "600 12px sans-serif"; // font has to be set before context.measureText()
|
||||||
|
|
||||||
|
const offsetX = (isSpeaking ? x + 0 : x) + width / 2;
|
||||||
|
const offsetY = (isSpeaking ? y + 0 : y) + height + 2;
|
||||||
|
const paddingHorizontal = 5;
|
||||||
|
const paddingVertical = 3;
|
||||||
|
const measure = context.measureText(username);
|
||||||
|
const measureHeight =
|
||||||
|
measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent;
|
||||||
|
const finalHeight = Math.max(measureHeight, 12);
|
||||||
|
|
||||||
|
const boxX = offsetX - 1;
|
||||||
|
const boxY = offsetY - 1;
|
||||||
|
const boxWidth = measure.width + 2 + paddingHorizontal * 2 + 2;
|
||||||
|
const boxHeight = finalHeight + 2 + paddingVertical * 2 + 2;
|
||||||
|
if (context.roundRect) {
|
||||||
|
context.beginPath();
|
||||||
|
context.roundRect(boxX, boxY, boxWidth, boxHeight, 8);
|
||||||
|
context.fillStyle = background;
|
||||||
|
context.fill();
|
||||||
|
context.strokeStyle = COLOR_WHITE;
|
||||||
|
context.stroke();
|
||||||
|
|
||||||
|
if (isSpeaking) {
|
||||||
|
context.beginPath();
|
||||||
|
context.roundRect(boxX - 2, boxY - 2, boxWidth + 4, boxHeight + 4, 8);
|
||||||
|
context.strokeStyle = IS_SPEAKING_COLOR;
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
roundRect(context, boxX, boxY, boxWidth, boxHeight, 8, COLOR_WHITE);
|
||||||
|
}
|
||||||
|
context.fillStyle = COLOR_CHARCOAL_BLACK;
|
||||||
|
|
||||||
|
context.fillText(
|
||||||
|
username,
|
||||||
|
offsetX + paddingHorizontal + 1,
|
||||||
|
offsetY +
|
||||||
|
paddingVertical +
|
||||||
|
measure.actualBoundingBoxAscent +
|
||||||
|
Math.floor((finalHeight - measureHeight) / 2) +
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
// draw three vertical bars signalling someone is speaking
|
||||||
|
if (isSpeaking) {
|
||||||
|
context.fillStyle = IS_SPEAKING_COLOR;
|
||||||
|
const barheight = 8;
|
||||||
|
const margin = 8;
|
||||||
|
const gap = 5;
|
||||||
|
context.fillRect(
|
||||||
|
boxX + boxWidth + margin,
|
||||||
|
boxY + (boxHeight / 2 - barheight / 2),
|
||||||
|
2,
|
||||||
|
barheight,
|
||||||
|
);
|
||||||
|
context.fillRect(
|
||||||
|
boxX + boxWidth + margin + gap,
|
||||||
|
boxY + (boxHeight / 2 - (barheight * 2) / 2),
|
||||||
|
2,
|
||||||
|
barheight * 2,
|
||||||
|
);
|
||||||
|
context.fillRect(
|
||||||
|
boxX + boxWidth + margin + gap * 2,
|
||||||
|
boxY + (boxHeight / 2 - barheight / 2),
|
||||||
|
2,
|
||||||
|
barheight,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context.restore();
|
||||||
|
context.closePath();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
@ -89,6 +89,7 @@ import {
|
||||||
TOOL_TYPE,
|
TOOL_TYPE,
|
||||||
EDITOR_LS_KEYS,
|
EDITOR_LS_KEYS,
|
||||||
isIOS,
|
isIOS,
|
||||||
|
supportsResizeObserver,
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import { ExportedElements, exportCanvas, loadFromBlob } from "../data";
|
import { ExportedElements, exportCanvas, loadFromBlob } from "../data";
|
||||||
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
||||||
|
@ -476,9 +477,6 @@ export const useExcalidrawSetAppState = () =>
|
||||||
export const useExcalidrawActionManager = () =>
|
export const useExcalidrawActionManager = () =>
|
||||||
useContext(ExcalidrawActionManagerContext);
|
useContext(ExcalidrawActionManagerContext);
|
||||||
|
|
||||||
const supportsResizeObserver =
|
|
||||||
typeof window !== "undefined" && "ResizeObserver" in window;
|
|
||||||
|
|
||||||
let didTapTwice: boolean = false;
|
let didTapTwice: boolean = false;
|
||||||
let tappedTwiceTimer = 0;
|
let tappedTwiceTimer = 0;
|
||||||
let isHoldingSpace: boolean = false;
|
let isHoldingSpace: boolean = false;
|
||||||
|
|
|
@ -9,8 +9,7 @@ type AvatarProps = {
|
||||||
color: string;
|
color: string;
|
||||||
name: string;
|
name: string;
|
||||||
src?: string;
|
src?: string;
|
||||||
isBeingFollowed?: boolean;
|
className?: string;
|
||||||
isCurrentUser: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Avatar = ({
|
export const Avatar = ({
|
||||||
|
@ -18,22 +17,14 @@ export const Avatar = ({
|
||||||
onClick,
|
onClick,
|
||||||
name,
|
name,
|
||||||
src,
|
src,
|
||||||
isBeingFollowed,
|
className,
|
||||||
isCurrentUser,
|
|
||||||
}: AvatarProps) => {
|
}: AvatarProps) => {
|
||||||
const shortName = getNameInitial(name);
|
const shortName = getNameInitial(name);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const loadImg = !error && src;
|
const loadImg = !error && src;
|
||||||
const style = loadImg ? undefined : { background: color };
|
const style = loadImg ? undefined : { background: color };
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={clsx("Avatar", className)} style={style} onClick={onClick}>
|
||||||
className={clsx("Avatar", {
|
|
||||||
"Avatar--is-followed": isBeingFollowed,
|
|
||||||
"Avatar--is-current-user": isCurrentUser,
|
|
||||||
})}
|
|
||||||
style={style}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{loadImg ? (
|
{loadImg ? (
|
||||||
<img
|
<img
|
||||||
className="Avatar-img"
|
className="Avatar-img"
|
||||||
|
|
|
@ -19,7 +19,14 @@
|
||||||
|
|
||||||
&__top-right {
|
&__top-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
pointer-events: none !important;
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
pointer-events: var(--ui-pointerEvents);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__footer {
|
&__footer {
|
||||||
|
|
|
@ -1,16 +1,25 @@
|
||||||
@import "../css/variables.module";
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
|
--avatar-size: 1.75rem;
|
||||||
|
--avatarList-gap: 0.625rem;
|
||||||
|
--userList-padding: var(--space-factor);
|
||||||
|
|
||||||
|
.UserList-wrapper {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.UserList {
|
.UserList {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
/*github corner*/
|
padding: var(--userList-padding);
|
||||||
padding: var(--space-factor) var(--space-factor) var(--space-factor)
|
|
||||||
var(--space-factor);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.625rem;
|
gap: var(--avatarList-gap);
|
||||||
|
|
||||||
&:empty {
|
&:empty {
|
||||||
display: none;
|
display: none;
|
||||||
|
@ -18,15 +27,16 @@
|
||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
// can fit max 4 avatars (3 avatars + show more) in a column
|
--max-size: calc(
|
||||||
max-height: 120px;
|
var(--avatar-size) * var(--max-avatars, 2) + var(--avatarList-gap) *
|
||||||
|
(var(--max-avatars, 2) - 1) + var(--userList-padding) * 2
|
||||||
|
);
|
||||||
|
|
||||||
// can fit max 4 avatars (3 avatars + show more) when there's enough space
|
// max width & height set to fix the max-avatars
|
||||||
max-width: 120px;
|
max-height: var(--max-size);
|
||||||
|
max-width: var(--max-size);
|
||||||
|
|
||||||
// Tweak in 30px increments to fit more/fewer avatars in a row/column ^^
|
// Tweak in 30px increments to fit more/fewer avatars in a row/column ^^
|
||||||
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.UserList > * {
|
.UserList > * {
|
||||||
|
@ -45,10 +55,11 @@
|
||||||
@include avatarStyles;
|
@include avatarStyles;
|
||||||
background-color: var(--color-gray-20);
|
background-color: var(--color-gray-20);
|
||||||
border: 0 !important;
|
border: 0 !important;
|
||||||
font-size: 0.5rem;
|
font-size: 0.625rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--color-gray-100);
|
color: var(--color-gray-100);
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.UserList__collaborator-name {
|
.UserList__collaborator-name {
|
||||||
|
@ -57,13 +68,82 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.UserList__collaborator-follow-status-icon {
|
.UserList__collaborator--avatar-only {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
.UserList__collaborator-status-icon {
|
||||||
|
--size: 14px;
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
bottom: -0.25rem;
|
||||||
|
right: -0.25rem;
|
||||||
|
width: var(--size);
|
||||||
|
height: var(--size);
|
||||||
|
svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: var(--size);
|
||||||
|
height: var(--size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.UserList__collaborator-status-icons {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 1rem;
|
min-width: 2.25rem;
|
||||||
|
gap: 0.25rem;
|
||||||
|
justify-content: flex-end;
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.UserList__collaborator.is-muted
|
||||||
|
.UserList__collaborator-status-icon-microphone-muted {
|
||||||
|
color: var(--color-danger);
|
||||||
|
filter: drop-shadow(0px 0px 0px rgba(0, 0, 0, 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.UserList__collaborator-status-icon-speaking-indicator {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: row nowrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 1rem;
|
||||||
|
padding: 0 3px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
div {
|
||||||
|
width: 0.125rem;
|
||||||
|
height: 0.4rem;
|
||||||
|
// keep this in sync with constants.ts
|
||||||
|
background-color: #a2f1a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
div:nth-of-type(1) {
|
||||||
|
animation: speaking-indicator-anim 1s -0.45s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
div:nth-of-type(2) {
|
||||||
|
animation: speaking-indicator-anim 1s -0.9s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
div:nth-of-type(3) {
|
||||||
|
animation: speaking-indicator-anim 1s -0.15s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes speaking-indicator-anim {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scaleY(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: scaleY(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
--userlist-hint-bg-color: var(--color-gray-10);
|
--userlist-hint-bg-color: var(--color-gray-10);
|
||||||
--userlist-hint-heading-color: var(--color-gray-80);
|
--userlist-hint-heading-color: var(--color-gray-80);
|
||||||
--userlist-hint-text-color: var(--color-gray-60);
|
--userlist-hint-text-color: var(--color-gray-60);
|
||||||
|
@ -80,7 +160,7 @@
|
||||||
position: static;
|
position: static;
|
||||||
top: auto;
|
top: auto;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
max-height: 12rem;
|
max-height: 50vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
border-top: 1px solid var(--userlist-collaborators-border-color);
|
border-top: 1px solid var(--userlist-collaborators-border-color);
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import "./UserList.scss";
|
import "./UserList.scss";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useLayoutEffect } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Collaborator, SocketId } from "../types";
|
import { Collaborator, SocketId } from "../types";
|
||||||
import { Tooltip } from "./Tooltip";
|
import { Tooltip } from "./Tooltip";
|
||||||
|
@ -12,9 +12,11 @@ import { Island } from "./Island";
|
||||||
import { searchIcon } from "./icons";
|
import { searchIcon } from "./icons";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { isShallowEqual } from "../utils";
|
import { isShallowEqual } from "../utils";
|
||||||
|
import { supportsResizeObserver } from "../constants";
|
||||||
|
import { MarkRequired } from "../utility-types";
|
||||||
|
|
||||||
export type GoToCollaboratorComponentProps = {
|
export type GoToCollaboratorComponentProps = {
|
||||||
clientId: ClientId;
|
socketId: SocketId;
|
||||||
collaborator: Collaborator;
|
collaborator: Collaborator;
|
||||||
withName: boolean;
|
withName: boolean;
|
||||||
isBeingFollowed: boolean;
|
isBeingFollowed: boolean;
|
||||||
|
@ -23,45 +25,41 @@ export type GoToCollaboratorComponentProps = {
|
||||||
/** collaborator user id or socket id (fallback) */
|
/** collaborator user id or socket id (fallback) */
|
||||||
type ClientId = string & { _brand: "UserId" };
|
type ClientId = string & { _brand: "UserId" };
|
||||||
|
|
||||||
const FIRST_N_AVATARS = 3;
|
const DEFAULT_MAX_AVATARS = 4;
|
||||||
const SHOW_COLLABORATORS_FILTER_AT = 8;
|
const SHOW_COLLABORATORS_FILTER_AT = 8;
|
||||||
|
|
||||||
const ConditionalTooltipWrapper = ({
|
const ConditionalTooltipWrapper = ({
|
||||||
shouldWrap,
|
shouldWrap,
|
||||||
children,
|
children,
|
||||||
clientId,
|
|
||||||
username,
|
username,
|
||||||
}: {
|
}: {
|
||||||
shouldWrap: boolean;
|
shouldWrap: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
clientId: ClientId;
|
|
||||||
}) =>
|
}) =>
|
||||||
shouldWrap ? (
|
shouldWrap ? (
|
||||||
<Tooltip label={username || "Unknown user"} key={clientId}>
|
<Tooltip label={username || "Unknown user"}>{children}</Tooltip>
|
||||||
{children}
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
) : (
|
||||||
<React.Fragment key={clientId}>{children}</React.Fragment>
|
<React.Fragment>{children}</React.Fragment>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderCollaborator = ({
|
const renderCollaborator = ({
|
||||||
actionManager,
|
actionManager,
|
||||||
collaborator,
|
collaborator,
|
||||||
clientId,
|
socketId,
|
||||||
withName = false,
|
withName = false,
|
||||||
shouldWrapWithTooltip = false,
|
shouldWrapWithTooltip = false,
|
||||||
isBeingFollowed,
|
isBeingFollowed,
|
||||||
}: {
|
}: {
|
||||||
actionManager: ActionManager;
|
actionManager: ActionManager;
|
||||||
collaborator: Collaborator;
|
collaborator: Collaborator;
|
||||||
clientId: ClientId;
|
socketId: SocketId;
|
||||||
withName?: boolean;
|
withName?: boolean;
|
||||||
shouldWrapWithTooltip?: boolean;
|
shouldWrapWithTooltip?: boolean;
|
||||||
isBeingFollowed: boolean;
|
isBeingFollowed: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const data: GoToCollaboratorComponentProps = {
|
const data: GoToCollaboratorComponentProps = {
|
||||||
clientId,
|
socketId,
|
||||||
collaborator,
|
collaborator,
|
||||||
withName,
|
withName,
|
||||||
isBeingFollowed,
|
isBeingFollowed,
|
||||||
|
@ -70,8 +68,7 @@ const renderCollaborator = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConditionalTooltipWrapper
|
<ConditionalTooltipWrapper
|
||||||
key={clientId}
|
key={socketId}
|
||||||
clientId={clientId}
|
|
||||||
username={collaborator.username}
|
username={collaborator.username}
|
||||||
shouldWrap={shouldWrapWithTooltip}
|
shouldWrap={shouldWrapWithTooltip}
|
||||||
>
|
>
|
||||||
|
@ -82,7 +79,13 @@ const renderCollaborator = ({
|
||||||
|
|
||||||
type UserListUserObject = Pick<
|
type UserListUserObject = Pick<
|
||||||
Collaborator,
|
Collaborator,
|
||||||
"avatarUrl" | "id" | "socketId" | "username"
|
| "avatarUrl"
|
||||||
|
| "id"
|
||||||
|
| "socketId"
|
||||||
|
| "username"
|
||||||
|
| "isInCall"
|
||||||
|
| "isSpeaking"
|
||||||
|
| "isMuted"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type UserListProps = {
|
type UserListProps = {
|
||||||
|
@ -97,13 +100,19 @@ const collaboratorComparatorKeys = [
|
||||||
"id",
|
"id",
|
||||||
"socketId",
|
"socketId",
|
||||||
"username",
|
"username",
|
||||||
|
"isInCall",
|
||||||
|
"isSpeaking",
|
||||||
|
"isMuted",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const UserList = React.memo(
|
export const UserList = React.memo(
|
||||||
({ className, mobile, collaborators, userToFollow }: UserListProps) => {
|
({ className, mobile, collaborators, userToFollow }: UserListProps) => {
|
||||||
const actionManager = useExcalidrawActionManager();
|
const actionManager = useExcalidrawActionManager();
|
||||||
|
|
||||||
const uniqueCollaboratorsMap = new Map<ClientId, Collaborator>();
|
const uniqueCollaboratorsMap = new Map<
|
||||||
|
ClientId,
|
||||||
|
MarkRequired<Collaborator, "socketId">
|
||||||
|
>();
|
||||||
|
|
||||||
collaborators.forEach((collaborator, socketId) => {
|
collaborators.forEach((collaborator, socketId) => {
|
||||||
const userId = (collaborator.id || socketId) as ClientId;
|
const userId = (collaborator.id || socketId) as ClientId;
|
||||||
|
@ -114,35 +123,62 @@ export const UserList = React.memo(
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const uniqueCollaboratorsArray = Array.from(uniqueCollaboratorsMap).filter(
|
const uniqueCollaboratorsArray = Array.from(
|
||||||
([_, collaborator]) => collaborator.username?.trim(),
|
uniqueCollaboratorsMap.values(),
|
||||||
);
|
).filter((collaborator) => collaborator.username?.trim());
|
||||||
|
|
||||||
const [searchTerm, setSearchTerm] = React.useState("");
|
const [searchTerm, setSearchTerm] = React.useState("");
|
||||||
|
|
||||||
if (uniqueCollaboratorsArray.length === 0) {
|
const userListWrapper = React.useRef<HTMLDivElement | null>(null);
|
||||||
return null;
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (userListWrapper.current) {
|
||||||
|
const updateMaxAvatars = (width: number) => {
|
||||||
|
const maxAvatars = Math.max(1, Math.min(8, Math.floor(width / 38)));
|
||||||
|
setMaxAvatars(maxAvatars);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateMaxAvatars(userListWrapper.current.clientWidth);
|
||||||
|
|
||||||
|
if (!supportsResizeObserver) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const { width } = entry.contentRect;
|
||||||
|
updateMaxAvatars(width);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(userListWrapper.current);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [maxAvatars, setMaxAvatars] = React.useState(DEFAULT_MAX_AVATARS);
|
||||||
|
|
||||||
const searchTermNormalized = searchTerm.trim().toLowerCase();
|
const searchTermNormalized = searchTerm.trim().toLowerCase();
|
||||||
|
|
||||||
const filteredCollaborators = searchTermNormalized
|
const filteredCollaborators = searchTermNormalized
|
||||||
? uniqueCollaboratorsArray.filter(([, collaborator]) =>
|
? uniqueCollaboratorsArray.filter((collaborator) =>
|
||||||
collaborator.username?.toLowerCase().includes(searchTerm),
|
collaborator.username?.toLowerCase().includes(searchTerm),
|
||||||
)
|
)
|
||||||
: uniqueCollaboratorsArray;
|
: uniqueCollaboratorsArray;
|
||||||
|
|
||||||
const firstNCollaborators = uniqueCollaboratorsArray.slice(
|
const firstNCollaborators = uniqueCollaboratorsArray.slice(
|
||||||
0,
|
0,
|
||||||
FIRST_N_AVATARS,
|
maxAvatars - 1,
|
||||||
);
|
);
|
||||||
|
|
||||||
const firstNAvatarsJSX = firstNCollaborators.map(
|
const firstNAvatarsJSX = firstNCollaborators.map((collaborator) =>
|
||||||
([clientId, collaborator]) =>
|
|
||||||
renderCollaborator({
|
renderCollaborator({
|
||||||
actionManager,
|
actionManager,
|
||||||
collaborator,
|
collaborator,
|
||||||
clientId,
|
socketId: collaborator.socketId,
|
||||||
shouldWrapWithTooltip: true,
|
shouldWrapWithTooltip: true,
|
||||||
isBeingFollowed: collaborator.socketId === userToFollow,
|
isBeingFollowed: collaborator.socketId === userToFollow,
|
||||||
}),
|
}),
|
||||||
|
@ -150,21 +186,25 @@ export const UserList = React.memo(
|
||||||
|
|
||||||
return mobile ? (
|
return mobile ? (
|
||||||
<div className={clsx("UserList UserList_mobile", className)}>
|
<div className={clsx("UserList UserList_mobile", className)}>
|
||||||
{uniqueCollaboratorsArray.map(([clientId, collaborator]) =>
|
{uniqueCollaboratorsArray.map((collaborator) =>
|
||||||
renderCollaborator({
|
renderCollaborator({
|
||||||
actionManager,
|
actionManager,
|
||||||
collaborator,
|
collaborator,
|
||||||
clientId,
|
socketId: collaborator.socketId,
|
||||||
shouldWrapWithTooltip: true,
|
shouldWrapWithTooltip: true,
|
||||||
isBeingFollowed: collaborator.socketId === userToFollow,
|
isBeingFollowed: collaborator.socketId === userToFollow,
|
||||||
}),
|
}),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={clsx("UserList", className)}>
|
<div className="UserList-wrapper" ref={userListWrapper}>
|
||||||
|
<div
|
||||||
|
className={clsx("UserList", className)}
|
||||||
|
style={{ [`--max-avatars` as any]: maxAvatars }}
|
||||||
|
>
|
||||||
{firstNAvatarsJSX}
|
{firstNAvatarsJSX}
|
||||||
|
|
||||||
{uniqueCollaboratorsArray.length > FIRST_N_AVATARS && (
|
{uniqueCollaboratorsArray.length > maxAvatars - 1 && (
|
||||||
<Popover.Root
|
<Popover.Root
|
||||||
onOpenChange={(isOpen) => {
|
onOpenChange={(isOpen) => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
|
@ -173,12 +213,12 @@ export const UserList = React.memo(
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Popover.Trigger className="UserList__more">
|
<Popover.Trigger className="UserList__more">
|
||||||
+{uniqueCollaboratorsArray.length - FIRST_N_AVATARS}
|
+{uniqueCollaboratorsArray.length - maxAvatars + 1}
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
<Popover.Content
|
<Popover.Content
|
||||||
style={{
|
style={{
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
width: "13rem",
|
width: "15rem",
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
}}
|
}}
|
||||||
align="end"
|
align="end"
|
||||||
|
@ -209,11 +249,11 @@ export const UserList = React.memo(
|
||||||
<div className="UserList__hint">
|
<div className="UserList__hint">
|
||||||
{t("userList.hint.text")}
|
{t("userList.hint.text")}
|
||||||
</div>
|
</div>
|
||||||
{filteredCollaborators.map(([clientId, collaborator]) =>
|
{filteredCollaborators.map((collaborator) =>
|
||||||
renderCollaborator({
|
renderCollaborator({
|
||||||
actionManager,
|
actionManager,
|
||||||
collaborator,
|
collaborator,
|
||||||
clientId,
|
socketId: collaborator.socketId,
|
||||||
withName: true,
|
withName: true,
|
||||||
isBeingFollowed: collaborator.socketId === userToFollow,
|
isBeingFollowed: collaborator.socketId === userToFollow,
|
||||||
}),
|
}),
|
||||||
|
@ -224,6 +264,7 @@ export const UserList = React.memo(
|
||||||
</Popover.Root>
|
</Popover.Root>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
(prev, next) => {
|
(prev, next) => {
|
||||||
|
@ -236,10 +277,15 @@ export const UserList = React.memo(
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextCollaboratorSocketIds = next.collaborators.keys();
|
||||||
|
|
||||||
for (const [socketId, collaborator] of prev.collaborators) {
|
for (const [socketId, collaborator] of prev.collaborators) {
|
||||||
const nextCollaborator = next.collaborators.get(socketId);
|
const nextCollaborator = next.collaborators.get(socketId);
|
||||||
if (
|
if (
|
||||||
!nextCollaborator ||
|
!nextCollaborator ||
|
||||||
|
// this checks order of collaborators in the map is the same
|
||||||
|
// as previous render
|
||||||
|
socketId !== nextCollaboratorSocketIds.next().value ||
|
||||||
!isShallowEqual(
|
!isShallowEqual(
|
||||||
collaborator,
|
collaborator,
|
||||||
nextCollaborator,
|
nextCollaborator,
|
||||||
|
|
|
@ -66,42 +66,46 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cursorButton: {
|
const remotePointerButton: InteractiveCanvasRenderConfig["remotePointerButton"] =
|
||||||
[id: string]: string | undefined;
|
new Map();
|
||||||
} = {};
|
const remotePointerViewportCoords: InteractiveCanvasRenderConfig["remotePointerViewportCoords"] =
|
||||||
const pointerViewportCoords: InteractiveCanvasRenderConfig["remotePointerViewportCoords"] =
|
new Map();
|
||||||
{};
|
|
||||||
const remoteSelectedElementIds: InteractiveCanvasRenderConfig["remoteSelectedElementIds"] =
|
const remoteSelectedElementIds: InteractiveCanvasRenderConfig["remoteSelectedElementIds"] =
|
||||||
{};
|
new Map();
|
||||||
const pointerUsernames: { [id: string]: string } = {};
|
const remotePointerUsernames: InteractiveCanvasRenderConfig["remotePointerUsernames"] =
|
||||||
const pointerUserStates: { [id: string]: string } = {};
|
new Map();
|
||||||
|
const remotePointerUserStates: InteractiveCanvasRenderConfig["remotePointerUserStates"] =
|
||||||
|
new Map();
|
||||||
|
|
||||||
props.appState.collaborators.forEach((user, socketId) => {
|
props.appState.collaborators.forEach((user, socketId) => {
|
||||||
if (user.selectedElementIds) {
|
if (user.selectedElementIds) {
|
||||||
for (const id of Object.keys(user.selectedElementIds)) {
|
for (const id of Object.keys(user.selectedElementIds)) {
|
||||||
if (!(id in remoteSelectedElementIds)) {
|
if (!remoteSelectedElementIds.has(id)) {
|
||||||
remoteSelectedElementIds[id] = [];
|
remoteSelectedElementIds.set(id, []);
|
||||||
}
|
}
|
||||||
remoteSelectedElementIds[id].push(socketId);
|
remoteSelectedElementIds.get(id)!.push(socketId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!user.pointer) {
|
if (!user.pointer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (user.username) {
|
if (user.username) {
|
||||||
pointerUsernames[socketId] = user.username;
|
remotePointerUsernames.set(socketId, user.username);
|
||||||
}
|
}
|
||||||
if (user.userState) {
|
if (user.userState) {
|
||||||
pointerUserStates[socketId] = user.userState;
|
remotePointerUserStates.set(socketId, user.userState);
|
||||||
}
|
}
|
||||||
pointerViewportCoords[socketId] = sceneCoordsToViewportCoords(
|
remotePointerViewportCoords.set(
|
||||||
|
socketId,
|
||||||
|
sceneCoordsToViewportCoords(
|
||||||
{
|
{
|
||||||
sceneX: user.pointer.x,
|
sceneX: user.pointer.x,
|
||||||
sceneY: user.pointer.y,
|
sceneY: user.pointer.y,
|
||||||
},
|
},
|
||||||
props.appState,
|
props.appState,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
cursorButton[socketId] = user.button;
|
remotePointerButton.set(socketId, user.button);
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectionColor =
|
const selectionColor =
|
||||||
|
@ -120,11 +124,11 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
|
||||||
scale: window.devicePixelRatio,
|
scale: window.devicePixelRatio,
|
||||||
appState: props.appState,
|
appState: props.appState,
|
||||||
renderConfig: {
|
renderConfig: {
|
||||||
remotePointerViewportCoords: pointerViewportCoords,
|
remotePointerViewportCoords,
|
||||||
remotePointerButton: cursorButton,
|
remotePointerButton,
|
||||||
remoteSelectedElementIds,
|
remoteSelectedElementIds,
|
||||||
remotePointerUsernames: pointerUsernames,
|
remotePointerUsernames,
|
||||||
remotePointerUserStates: pointerUserStates,
|
remotePointerUserStates,
|
||||||
selectionColor,
|
selectionColor,
|
||||||
renderScrollbars: false,
|
renderScrollbars: false,
|
||||||
},
|
},
|
||||||
|
|
|
@ -1798,7 +1798,7 @@ export const fullscreenIcon = createIcon(
|
||||||
);
|
);
|
||||||
|
|
||||||
export const eyeIcon = createIcon(
|
export const eyeIcon = createIcon(
|
||||||
<g stroke="currentColor" fill="none">
|
<g stroke="currentColor" fill="none" strokeWidth={1.5}>
|
||||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
|
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
|
||||||
<path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
|
<path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
|
||||||
|
@ -1837,3 +1837,26 @@ export const searchIcon = createIcon(
|
||||||
</g>,
|
</g>,
|
||||||
tablerIconProps,
|
tablerIconProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const microphoneIcon = createIcon(
|
||||||
|
<g strokeWidth={1.5}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M9 2m0 3a3 3 0 0 1 3 -3h0a3 3 0 0 1 3 3v5a3 3 0 0 1 -3 3h0a3 3 0 0 1 -3 -3z" />
|
||||||
|
<path d="M5 10a7 7 0 0 0 14 0" />
|
||||||
|
<path d="M8 21l8 0" />
|
||||||
|
<path d="M12 17l0 4" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const microphoneMutedIcon = createIcon(
|
||||||
|
<g strokeWidth={1.5}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M3 3l18 18" />
|
||||||
|
<path d="M9 5a3 3 0 0 1 6 0v5a3 3 0 0 1 -.13 .874m-2 2a3 3 0 0 1 -3.87 -2.872v-1" />
|
||||||
|
<path d="M5 10a7 7 0 0 0 10.846 5.85m2 -2a6.967 6.967 0 0 0 1.152 -3.85" />
|
||||||
|
<path d="M8 21l8 0" />
|
||||||
|
<path d="M12 17l0 4" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
|
@ -20,6 +20,9 @@ export const isIOS =
|
||||||
export const isBrave = () =>
|
export const isBrave = () =>
|
||||||
(navigator as any).brave?.isBrave?.name === "isBrave";
|
(navigator as any).brave?.isBrave?.name === "isBrave";
|
||||||
|
|
||||||
|
export const supportsResizeObserver =
|
||||||
|
typeof window !== "undefined" && "ResizeObserver" in window;
|
||||||
|
|
||||||
export const APP_NAME = "Excalidraw";
|
export const APP_NAME = "Excalidraw";
|
||||||
|
|
||||||
export const DRAGGING_THRESHOLD = 10; // px
|
export const DRAGGING_THRESHOLD = 10; // px
|
||||||
|
@ -144,6 +147,11 @@ export const DEFAULT_VERTICAL_ALIGN = "top";
|
||||||
export const DEFAULT_VERSION = "{version}";
|
export const DEFAULT_VERSION = "{version}";
|
||||||
export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2;
|
export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2;
|
||||||
|
|
||||||
|
export const COLOR_WHITE = "#ffffff";
|
||||||
|
export const COLOR_CHARCOAL_BLACK = "#1e1e1e";
|
||||||
|
// keep this in sync with CSS
|
||||||
|
export const COLOR_VOICE_CALL = "#a2f1a6";
|
||||||
|
|
||||||
export const CANVAS_ONLY_ACTIONS = ["selectAll"];
|
export const CANVAS_ONLY_ACTIONS = ["selectAll"];
|
||||||
|
|
||||||
export const GRID_SIZE = 20; // TODO make it configurable?
|
export const GRID_SIZE = 20; // TODO make it configurable?
|
||||||
|
|
|
@ -116,8 +116,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin avatarStyles {
|
@mixin avatarStyles {
|
||||||
width: 1.25rem;
|
width: var(--avatar-size, 1.5rem);
|
||||||
height: 1.25rem;
|
height: var(--avatar-size, 1.5rem);
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
|
@ -131,6 +131,10 @@
|
||||||
color: var(--color-gray-90);
|
color: var(--color-gray-90);
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
|
||||||
&-img {
|
&-img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
@ -144,14 +148,14 @@
|
||||||
right: -3px;
|
right: -3px;
|
||||||
bottom: -3px;
|
bottom: -3px;
|
||||||
left: -3px;
|
left: -3px;
|
||||||
border: 1px solid var(--avatar-border-color);
|
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--is-followed::before {
|
&.is-followed::before {
|
||||||
border-color: var(--color-primary-hover);
|
border-color: var(--color-primary-hover);
|
||||||
|
box-shadow: 0 0 0 1px var(--color-primary-hover);
|
||||||
}
|
}
|
||||||
&--is-current-user {
|
&.is-current-user {
|
||||||
cursor: auto;
|
cursor: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -84,7 +84,7 @@ export class LaserTrails implements Trail {
|
||||||
if (!this.collabTrails.has(key)) {
|
if (!this.collabTrails.has(key)) {
|
||||||
trail = new AnimatedTrail(this.animationFrameHandler, this.app, {
|
trail = new AnimatedTrail(this.animationFrameHandler, this.app, {
|
||||||
...this.getTrailOptions(),
|
...this.getTrailOptions(),
|
||||||
fill: () => getClientColor(key),
|
fill: () => getClientColor(key, collabolator),
|
||||||
});
|
});
|
||||||
trail.start(this.container);
|
trail.start(this.container);
|
||||||
|
|
||||||
|
|
|
@ -534,7 +534,10 @@
|
||||||
},
|
},
|
||||||
"hint": {
|
"hint": {
|
||||||
"text": "Click on user to follow",
|
"text": "Click on user to follow",
|
||||||
"followStatus": "You're currently following this user"
|
"followStatus": "You're currently following this user",
|
||||||
|
"inCall": "User is in a voice call",
|
||||||
|
"micMuted": "User's microphone is muted",
|
||||||
|
"isSpeaking": "User is speaking"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,7 @@ import {
|
||||||
} from "../scene/scrollbars";
|
} from "../scene/scrollbars";
|
||||||
|
|
||||||
import { renderSelectionElement } from "../renderer/renderElement";
|
import { renderSelectionElement } from "../renderer/renderElement";
|
||||||
import { getClientColor } from "../clients";
|
import { getClientColor, renderRemoteCursors } from "../clients";
|
||||||
import {
|
import {
|
||||||
isSelectedViaGroup,
|
isSelectedViaGroup,
|
||||||
getSelectedGroupIds,
|
getSelectedGroupIds,
|
||||||
|
@ -29,7 +29,7 @@ import {
|
||||||
TransformHandleType,
|
TransformHandleType,
|
||||||
} from "../element/transformHandles";
|
} from "../element/transformHandles";
|
||||||
import { arrayToMap, throttleRAF } from "../utils";
|
import { arrayToMap, throttleRAF } from "../utils";
|
||||||
import { InteractiveCanvasAppState, Point, UserIdleState } from "../types";
|
import { InteractiveCanvasAppState, Point } from "../types";
|
||||||
import { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE } from "../constants";
|
import { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE } from "../constants";
|
||||||
|
|
||||||
import { renderSnaps } from "../renderer/renderSnaps";
|
import { renderSnaps } from "../renderer/renderSnaps";
|
||||||
|
@ -726,14 +726,18 @@ const _renderInteractiveScene = ({
|
||||||
selectionColors.push(selectionColor);
|
selectionColors.push(selectionColor);
|
||||||
}
|
}
|
||||||
// remote users
|
// remote users
|
||||||
if (renderConfig.remoteSelectedElementIds[element.id]) {
|
const remoteClients = renderConfig.remoteSelectedElementIds.get(
|
||||||
|
element.id,
|
||||||
|
);
|
||||||
|
if (remoteClients) {
|
||||||
selectionColors.push(
|
selectionColors.push(
|
||||||
...renderConfig.remoteSelectedElementIds[element.id].map(
|
...remoteClients.map((socketId) => {
|
||||||
(socketId: string) => {
|
const background = getClientColor(
|
||||||
const background = getClientColor(socketId);
|
socketId,
|
||||||
|
appState.collaborators.get(socketId),
|
||||||
|
);
|
||||||
return background;
|
return background;
|
||||||
},
|
}),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -747,7 +751,7 @@ const _renderInteractiveScene = ({
|
||||||
elementX2,
|
elementX2,
|
||||||
elementY2,
|
elementY2,
|
||||||
selectionColors,
|
selectionColors,
|
||||||
dashed: !!renderConfig.remoteSelectedElementIds[element.id],
|
dashed: !!remoteClients,
|
||||||
cx,
|
cx,
|
||||||
cy,
|
cy,
|
||||||
activeEmbeddable:
|
activeEmbeddable:
|
||||||
|
@ -858,143 +862,13 @@ const _renderInteractiveScene = ({
|
||||||
// Reset zoom
|
// Reset zoom
|
||||||
context.restore();
|
context.restore();
|
||||||
|
|
||||||
// Paint remote pointers
|
renderRemoteCursors({
|
||||||
for (const clientId in renderConfig.remotePointerViewportCoords) {
|
context,
|
||||||
let { x, y } = renderConfig.remotePointerViewportCoords[clientId];
|
renderConfig,
|
||||||
|
appState,
|
||||||
x -= appState.offsetLeft;
|
normalizedWidth,
|
||||||
y -= appState.offsetTop;
|
normalizedHeight,
|
||||||
|
});
|
||||||
const width = 11;
|
|
||||||
const height = 14;
|
|
||||||
|
|
||||||
const isOutOfBounds =
|
|
||||||
x < 0 ||
|
|
||||||
x > normalizedWidth - width ||
|
|
||||||
y < 0 ||
|
|
||||||
y > normalizedHeight - height;
|
|
||||||
|
|
||||||
x = Math.max(x, 0);
|
|
||||||
x = Math.min(x, normalizedWidth - width);
|
|
||||||
y = Math.max(y, 0);
|
|
||||||
y = Math.min(y, normalizedHeight - height);
|
|
||||||
|
|
||||||
const background = getClientColor(clientId);
|
|
||||||
|
|
||||||
context.save();
|
|
||||||
context.strokeStyle = background;
|
|
||||||
context.fillStyle = background;
|
|
||||||
|
|
||||||
const userState = renderConfig.remotePointerUserStates[clientId];
|
|
||||||
const isInactive =
|
|
||||||
isOutOfBounds ||
|
|
||||||
userState === UserIdleState.IDLE ||
|
|
||||||
userState === UserIdleState.AWAY;
|
|
||||||
|
|
||||||
if (isInactive) {
|
|
||||||
context.globalAlpha = 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
renderConfig.remotePointerButton &&
|
|
||||||
renderConfig.remotePointerButton[clientId] === "down"
|
|
||||||
) {
|
|
||||||
context.beginPath();
|
|
||||||
context.arc(x, y, 15, 0, 2 * Math.PI, false);
|
|
||||||
context.lineWidth = 3;
|
|
||||||
context.strokeStyle = "#ffffff88";
|
|
||||||
context.stroke();
|
|
||||||
context.closePath();
|
|
||||||
|
|
||||||
context.beginPath();
|
|
||||||
context.arc(x, y, 15, 0, 2 * Math.PI, false);
|
|
||||||
context.lineWidth = 1;
|
|
||||||
context.strokeStyle = background;
|
|
||||||
context.stroke();
|
|
||||||
context.closePath();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Background (white outline) for arrow
|
|
||||||
context.fillStyle = oc.white;
|
|
||||||
context.strokeStyle = oc.white;
|
|
||||||
context.lineWidth = 6;
|
|
||||||
context.lineJoin = "round";
|
|
||||||
context.beginPath();
|
|
||||||
context.moveTo(x, y);
|
|
||||||
context.lineTo(x + 0, y + 14);
|
|
||||||
context.lineTo(x + 4, y + 9);
|
|
||||||
context.lineTo(x + 11, y + 8);
|
|
||||||
context.closePath();
|
|
||||||
context.stroke();
|
|
||||||
context.fill();
|
|
||||||
|
|
||||||
// Arrow
|
|
||||||
context.fillStyle = background;
|
|
||||||
context.strokeStyle = background;
|
|
||||||
context.lineWidth = 2;
|
|
||||||
context.lineJoin = "round";
|
|
||||||
context.beginPath();
|
|
||||||
if (isInactive) {
|
|
||||||
context.moveTo(x - 1, y - 1);
|
|
||||||
context.lineTo(x - 1, y + 15);
|
|
||||||
context.lineTo(x + 5, y + 10);
|
|
||||||
context.lineTo(x + 12, y + 9);
|
|
||||||
context.closePath();
|
|
||||||
context.fill();
|
|
||||||
} else {
|
|
||||||
context.moveTo(x, y);
|
|
||||||
context.lineTo(x + 0, y + 14);
|
|
||||||
context.lineTo(x + 4, y + 9);
|
|
||||||
context.lineTo(x + 11, y + 8);
|
|
||||||
context.closePath();
|
|
||||||
context.fill();
|
|
||||||
context.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
const username = renderConfig.remotePointerUsernames[clientId] || "";
|
|
||||||
|
|
||||||
if (!isOutOfBounds && username) {
|
|
||||||
context.font = "600 12px sans-serif"; // font has to be set before context.measureText()
|
|
||||||
|
|
||||||
const offsetX = x + width / 2;
|
|
||||||
const offsetY = y + height + 2;
|
|
||||||
const paddingHorizontal = 5;
|
|
||||||
const paddingVertical = 3;
|
|
||||||
const measure = context.measureText(username);
|
|
||||||
const measureHeight =
|
|
||||||
measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent;
|
|
||||||
const finalHeight = Math.max(measureHeight, 12);
|
|
||||||
|
|
||||||
const boxX = offsetX - 1;
|
|
||||||
const boxY = offsetY - 1;
|
|
||||||
const boxWidth = measure.width + 2 + paddingHorizontal * 2 + 2;
|
|
||||||
const boxHeight = finalHeight + 2 + paddingVertical * 2 + 2;
|
|
||||||
if (context.roundRect) {
|
|
||||||
context.beginPath();
|
|
||||||
context.roundRect(boxX, boxY, boxWidth, boxHeight, 8);
|
|
||||||
context.fillStyle = background;
|
|
||||||
context.fill();
|
|
||||||
context.strokeStyle = oc.white;
|
|
||||||
context.stroke();
|
|
||||||
} else {
|
|
||||||
roundRect(context, boxX, boxY, boxWidth, boxHeight, 8, oc.white);
|
|
||||||
}
|
|
||||||
context.fillStyle = oc.black;
|
|
||||||
|
|
||||||
context.fillText(
|
|
||||||
username,
|
|
||||||
offsetX + paddingHorizontal + 1,
|
|
||||||
offsetY +
|
|
||||||
paddingVertical +
|
|
||||||
measure.actualBoundingBoxAscent +
|
|
||||||
Math.floor((finalHeight - measureHeight) / 2) +
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
context.restore();
|
|
||||||
context.closePath();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paint scrollbars
|
// Paint scrollbars
|
||||||
let scrollBars;
|
let scrollBars;
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import type { RoughCanvas } from "roughjs/bin/canvas";
|
import type { RoughCanvas } from "roughjs/bin/canvas";
|
||||||
import { Drawable } from "roughjs/bin/core";
|
import { Drawable } from "roughjs/bin/core";
|
||||||
import {
|
import {
|
||||||
|
ExcalidrawElement,
|
||||||
ExcalidrawTextElement,
|
ExcalidrawTextElement,
|
||||||
NonDeletedElementsMap,
|
NonDeletedElementsMap,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
|
@ -13,6 +14,8 @@ import {
|
||||||
ElementsPendingErasure,
|
ElementsPendingErasure,
|
||||||
InteractiveCanvasAppState,
|
InteractiveCanvasAppState,
|
||||||
StaticCanvasAppState,
|
StaticCanvasAppState,
|
||||||
|
SocketId,
|
||||||
|
UserIdleState,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { MakeBrand } from "../utility-types";
|
import { MakeBrand } from "../utility-types";
|
||||||
|
|
||||||
|
@ -46,11 +49,11 @@ export type SVGRenderConfig = {
|
||||||
export type InteractiveCanvasRenderConfig = {
|
export type InteractiveCanvasRenderConfig = {
|
||||||
// collab-related state
|
// collab-related state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
remoteSelectedElementIds: { [elementId: string]: string[] };
|
remoteSelectedElementIds: Map<ExcalidrawElement["id"], SocketId[]>;
|
||||||
remotePointerViewportCoords: { [id: string]: { x: number; y: number } };
|
remotePointerViewportCoords: Map<SocketId, { x: number; y: number }>;
|
||||||
remotePointerUserStates: { [id: string]: string };
|
remotePointerUserStates: Map<SocketId, UserIdleState>;
|
||||||
remotePointerUsernames: { [id: string]: string };
|
remotePointerUsernames: Map<SocketId, string>;
|
||||||
remotePointerButton?: { [id: string]: string | undefined };
|
remotePointerButton: Map<SocketId, string | undefined>;
|
||||||
selectionColor?: string;
|
selectionColor?: string;
|
||||||
// extra options passed to the renderer
|
// extra options passed to the renderer
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
@ -61,6 +61,9 @@ export type Collaborator = Readonly<{
|
||||||
id?: string;
|
id?: string;
|
||||||
socketId?: SocketId;
|
socketId?: SocketId;
|
||||||
isCurrentUser?: boolean;
|
isCurrentUser?: boolean;
|
||||||
|
isInCall?: boolean;
|
||||||
|
isSpeaking?: boolean;
|
||||||
|
isMuted?: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type CollaboratorPointer = {
|
export type CollaboratorPointer = {
|
||||||
|
@ -319,9 +322,9 @@ export interface AppState {
|
||||||
y: number;
|
y: number;
|
||||||
} | null;
|
} | null;
|
||||||
objectsSnapModeEnabled: boolean;
|
objectsSnapModeEnabled: boolean;
|
||||||
/** the user's clientId & username who is being followed on the canvas */
|
/** the user's socket id & username who is being followed on the canvas */
|
||||||
userToFollow: UserToFollow | null;
|
userToFollow: UserToFollow | null;
|
||||||
/** the clientIds of the users following the current user */
|
/** the socket ids of the users following the current user */
|
||||||
followedBy: Set<SocketId>;
|
followedBy: Set<SocketId>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -791,6 +791,14 @@ export const isShallowEqual = <
|
||||||
const aKeys = Object.keys(objA);
|
const aKeys = Object.keys(objA);
|
||||||
const bKeys = Object.keys(objB);
|
const bKeys = Object.keys(objB);
|
||||||
if (aKeys.length !== bKeys.length) {
|
if (aKeys.length !== bKeys.length) {
|
||||||
|
if (debug) {
|
||||||
|
console.warn(
|
||||||
|
`%cisShallowEqual: objects don't have same properties ->`,
|
||||||
|
"color: #8B4000",
|
||||||
|
objA,
|
||||||
|
objB,
|
||||||
|
);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue