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 {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.footer-center {
|
||||
|
|
|
@ -1,10 +1,15 @@
|
|||
import { getClientColor } from "../clients";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { GoToCollaboratorComponentProps } from "../components/UserList";
|
||||
import { eyeIcon } from "../components/icons";
|
||||
import {
|
||||
eyeIcon,
|
||||
microphoneIcon,
|
||||
microphoneMutedIcon,
|
||||
} from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { Collaborator } from "../types";
|
||||
import { register } from "./register";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const actionGoToCollaborator = register({
|
||||
name: "goToCollaborator",
|
||||
|
@ -39,14 +44,45 @@ export const actionGoToCollaborator = register({
|
|||
};
|
||||
},
|
||||
PanelComponent: ({ updateData, data, appState }) => {
|
||||
const { clientId, collaborator, withName, isBeingFollowed } =
|
||||
const { socketId, collaborator, withName, isBeingFollowed } =
|
||||
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 ? (
|
||||
<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)}
|
||||
>
|
||||
<Avatar
|
||||
|
@ -54,32 +90,42 @@ export const actionGoToCollaborator = register({
|
|||
onClick={() => {}}
|
||||
name={collaborator.username || ""}
|
||||
src={collaborator.avatarUrl}
|
||||
isBeingFollowed={isBeingFollowed}
|
||||
isCurrentUser={collaborator.isCurrentUser === true}
|
||||
className={statusClassNames}
|
||||
/>
|
||||
<div className="UserList__collaborator-name">
|
||||
{collaborator.username}
|
||||
</div>
|
||||
<div
|
||||
className="UserList__collaborator-follow-status-icon"
|
||||
style={{ visibility: isBeingFollowed ? "visible" : "hidden" }}
|
||||
title={isBeingFollowed ? t("userList.hint.followStatus") : undefined}
|
||||
aria-hidden
|
||||
>
|
||||
{eyeIcon}
|
||||
<div className="UserList__collaborator-status-icons" aria-hidden>
|
||||
{isBeingFollowed && (
|
||||
<div
|
||||
className="UserList__collaborator-status-icon-is-followed"
|
||||
title={t("userList.hint.followStatus")}
|
||||
>
|
||||
{eyeIcon}
|
||||
</div>
|
||||
)}
|
||||
{statusIconJSX}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Avatar
|
||||
color={background}
|
||||
onClick={() => {
|
||||
updateData(collaborator);
|
||||
}}
|
||||
name={collaborator.username || ""}
|
||||
src={collaborator.avatarUrl}
|
||||
isBeingFollowed={isBeingFollowed}
|
||||
isCurrentUser={collaborator.isCurrentUser === true}
|
||||
/>
|
||||
<div
|
||||
className={`UserList__collaborator UserList__collaborator--avatar-only ${statusClassNames}`}
|
||||
>
|
||||
<Avatar
|
||||
color={background}
|
||||
onClick={() => {
|
||||
updateData(collaborator);
|
||||
}}
|
||||
name={collaborator.username || ""}
|
||||
src={collaborator.avatarUrl}
|
||||
className={statusClassNames}
|
||||
/>
|
||||
{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) {
|
||||
let hash = 0;
|
||||
if (id.length === 0) {
|
||||
|
@ -11,14 +26,12 @@ function hashToInteger(id: string) {
|
|||
}
|
||||
|
||||
export const getClientColor = (
|
||||
/**
|
||||
* any uniquely identifying key, such as user id or socket id
|
||||
*/
|
||||
id: string,
|
||||
socketId: SocketId,
|
||||
collaborator: Collaborator | undefined,
|
||||
) => {
|
||||
// to get more even distribution in case `id` is not uniformly distributed to
|
||||
// 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
|
||||
// words a hue value of step size 10). There are 37 such values including 0.
|
||||
const hue = (hash % 37) * 10;
|
||||
|
@ -38,3 +51,209 @@ export const getNameInitial = (name?: string | null) => {
|
|||
firstCodePoint ? String.fromCodePoint(firstCodePoint) : "?"
|
||||
).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,
|
||||
EDITOR_LS_KEYS,
|
||||
isIOS,
|
||||
supportsResizeObserver,
|
||||
} from "../constants";
|
||||
import { ExportedElements, exportCanvas, loadFromBlob } from "../data";
|
||||
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
||||
|
@ -476,9 +477,6 @@ export const useExcalidrawSetAppState = () =>
|
|||
export const useExcalidrawActionManager = () =>
|
||||
useContext(ExcalidrawActionManagerContext);
|
||||
|
||||
const supportsResizeObserver =
|
||||
typeof window !== "undefined" && "ResizeObserver" in window;
|
||||
|
||||
let didTapTwice: boolean = false;
|
||||
let tappedTwiceTimer = 0;
|
||||
let isHoldingSpace: boolean = false;
|
||||
|
|
|
@ -9,8 +9,7 @@ type AvatarProps = {
|
|||
color: string;
|
||||
name: string;
|
||||
src?: string;
|
||||
isBeingFollowed?: boolean;
|
||||
isCurrentUser: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const Avatar = ({
|
||||
|
@ -18,22 +17,14 @@ export const Avatar = ({
|
|||
onClick,
|
||||
name,
|
||||
src,
|
||||
isBeingFollowed,
|
||||
isCurrentUser,
|
||||
className,
|
||||
}: AvatarProps) => {
|
||||
const shortName = getNameInitial(name);
|
||||
const [error, setError] = useState(false);
|
||||
const loadImg = !error && src;
|
||||
const style = loadImg ? undefined : { background: color };
|
||||
return (
|
||||
<div
|
||||
className={clsx("Avatar", {
|
||||
"Avatar--is-followed": isBeingFollowed,
|
||||
"Avatar--is-current-user": isCurrentUser,
|
||||
})}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className={clsx("Avatar", className)} style={style} onClick={onClick}>
|
||||
{loadImg ? (
|
||||
<img
|
||||
className="Avatar-img"
|
||||
|
|
|
@ -19,7 +19,14 @@
|
|||
|
||||
&__top-right {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
pointer-events: none !important;
|
||||
|
||||
& > * {
|
||||
pointer-events: var(--ui-pointerEvents);
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
|
|
|
@ -1,16 +1,25 @@
|
|||
@import "../css/variables.module";
|
||||
|
||||
.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 {
|
||||
pointer-events: none;
|
||||
/*github corner*/
|
||||
padding: var(--space-factor) var(--space-factor) var(--space-factor)
|
||||
var(--space-factor);
|
||||
padding: var(--userList-padding);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
gap: var(--avatarList-gap);
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
|
@ -18,15 +27,16 @@
|
|||
|
||||
box-sizing: border-box;
|
||||
|
||||
// can fit max 4 avatars (3 avatars + show more) in a column
|
||||
max-height: 120px;
|
||||
--max-size: calc(
|
||||
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: 120px;
|
||||
// max width & height set to fix the max-avatars
|
||||
max-height: var(--max-size);
|
||||
max-width: var(--max-size);
|
||||
|
||||
// Tweak in 30px increments to fit more/fewer avatars in a row/column ^^
|
||||
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.UserList > * {
|
||||
|
@ -45,10 +55,11 @@
|
|||
@include avatarStyles;
|
||||
background-color: var(--color-gray-20);
|
||||
border: 0 !important;
|
||||
font-size: 0.5rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 400;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-gray-100);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.UserList__collaborator-name {
|
||||
|
@ -57,13 +68,82 @@
|
|||
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;
|
||||
flex: 0 0 auto;
|
||||
width: 1rem;
|
||||
min-width: 2.25rem;
|
||||
gap: 0.25rem;
|
||||
justify-content: flex-end;
|
||||
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-heading-color: var(--color-gray-80);
|
||||
--userlist-hint-text-color: var(--color-gray-60);
|
||||
|
@ -80,7 +160,7 @@
|
|||
position: static;
|
||||
top: auto;
|
||||
margin-top: 0;
|
||||
max-height: 12rem;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-top: 1px solid var(--userlist-collaborators-border-color);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import "./UserList.scss";
|
||||
|
||||
import React from "react";
|
||||
import React, { useLayoutEffect } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Collaborator, SocketId } from "../types";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
@ -12,9 +12,11 @@ import { Island } from "./Island";
|
|||
import { searchIcon } from "./icons";
|
||||
import { t } from "../i18n";
|
||||
import { isShallowEqual } from "../utils";
|
||||
import { supportsResizeObserver } from "../constants";
|
||||
import { MarkRequired } from "../utility-types";
|
||||
|
||||
export type GoToCollaboratorComponentProps = {
|
||||
clientId: ClientId;
|
||||
socketId: SocketId;
|
||||
collaborator: Collaborator;
|
||||
withName: boolean;
|
||||
isBeingFollowed: boolean;
|
||||
|
@ -23,45 +25,41 @@ export type GoToCollaboratorComponentProps = {
|
|||
/** collaborator user id or socket id (fallback) */
|
||||
type ClientId = string & { _brand: "UserId" };
|
||||
|
||||
const FIRST_N_AVATARS = 3;
|
||||
const DEFAULT_MAX_AVATARS = 4;
|
||||
const SHOW_COLLABORATORS_FILTER_AT = 8;
|
||||
|
||||
const ConditionalTooltipWrapper = ({
|
||||
shouldWrap,
|
||||
children,
|
||||
clientId,
|
||||
username,
|
||||
}: {
|
||||
shouldWrap: boolean;
|
||||
children: React.ReactNode;
|
||||
username?: string | null;
|
||||
clientId: ClientId;
|
||||
}) =>
|
||||
shouldWrap ? (
|
||||
<Tooltip label={username || "Unknown user"} key={clientId}>
|
||||
{children}
|
||||
</Tooltip>
|
||||
<Tooltip label={username || "Unknown user"}>{children}</Tooltip>
|
||||
) : (
|
||||
<React.Fragment key={clientId}>{children}</React.Fragment>
|
||||
<React.Fragment>{children}</React.Fragment>
|
||||
);
|
||||
|
||||
const renderCollaborator = ({
|
||||
actionManager,
|
||||
collaborator,
|
||||
clientId,
|
||||
socketId,
|
||||
withName = false,
|
||||
shouldWrapWithTooltip = false,
|
||||
isBeingFollowed,
|
||||
}: {
|
||||
actionManager: ActionManager;
|
||||
collaborator: Collaborator;
|
||||
clientId: ClientId;
|
||||
socketId: SocketId;
|
||||
withName?: boolean;
|
||||
shouldWrapWithTooltip?: boolean;
|
||||
isBeingFollowed: boolean;
|
||||
}) => {
|
||||
const data: GoToCollaboratorComponentProps = {
|
||||
clientId,
|
||||
socketId,
|
||||
collaborator,
|
||||
withName,
|
||||
isBeingFollowed,
|
||||
|
@ -70,8 +68,7 @@ const renderCollaborator = ({
|
|||
|
||||
return (
|
||||
<ConditionalTooltipWrapper
|
||||
key={clientId}
|
||||
clientId={clientId}
|
||||
key={socketId}
|
||||
username={collaborator.username}
|
||||
shouldWrap={shouldWrapWithTooltip}
|
||||
>
|
||||
|
@ -82,7 +79,13 @@ const renderCollaborator = ({
|
|||
|
||||
type UserListUserObject = Pick<
|
||||
Collaborator,
|
||||
"avatarUrl" | "id" | "socketId" | "username"
|
||||
| "avatarUrl"
|
||||
| "id"
|
||||
| "socketId"
|
||||
| "username"
|
||||
| "isInCall"
|
||||
| "isSpeaking"
|
||||
| "isMuted"
|
||||
>;
|
||||
|
||||
type UserListProps = {
|
||||
|
@ -97,13 +100,19 @@ const collaboratorComparatorKeys = [
|
|||
"id",
|
||||
"socketId",
|
||||
"username",
|
||||
"isInCall",
|
||||
"isSpeaking",
|
||||
"isMuted",
|
||||
] as const;
|
||||
|
||||
export const UserList = React.memo(
|
||||
({ className, mobile, collaborators, userToFollow }: UserListProps) => {
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
const uniqueCollaboratorsMap = new Map<ClientId, Collaborator>();
|
||||
const uniqueCollaboratorsMap = new Map<
|
||||
ClientId,
|
||||
MarkRequired<Collaborator, "socketId">
|
||||
>();
|
||||
|
||||
collaborators.forEach((collaborator, socketId) => {
|
||||
const userId = (collaborator.id || socketId) as ClientId;
|
||||
|
@ -114,115 +123,147 @@ export const UserList = React.memo(
|
|||
);
|
||||
});
|
||||
|
||||
const uniqueCollaboratorsArray = Array.from(uniqueCollaboratorsMap).filter(
|
||||
([_, collaborator]) => collaborator.username?.trim(),
|
||||
);
|
||||
const uniqueCollaboratorsArray = Array.from(
|
||||
uniqueCollaboratorsMap.values(),
|
||||
).filter((collaborator) => collaborator.username?.trim());
|
||||
|
||||
const [searchTerm, setSearchTerm] = React.useState("");
|
||||
|
||||
if (uniqueCollaboratorsArray.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const userListWrapper = React.useRef<HTMLDivElement | null>(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 filteredCollaborators = searchTermNormalized
|
||||
? uniqueCollaboratorsArray.filter(([, collaborator]) =>
|
||||
? uniqueCollaboratorsArray.filter((collaborator) =>
|
||||
collaborator.username?.toLowerCase().includes(searchTerm),
|
||||
)
|
||||
: uniqueCollaboratorsArray;
|
||||
|
||||
const firstNCollaborators = uniqueCollaboratorsArray.slice(
|
||||
0,
|
||||
FIRST_N_AVATARS,
|
||||
maxAvatars - 1,
|
||||
);
|
||||
|
||||
const firstNAvatarsJSX = firstNCollaborators.map(
|
||||
([clientId, collaborator]) =>
|
||||
renderCollaborator({
|
||||
actionManager,
|
||||
collaborator,
|
||||
clientId,
|
||||
shouldWrapWithTooltip: true,
|
||||
isBeingFollowed: collaborator.socketId === userToFollow,
|
||||
}),
|
||||
const firstNAvatarsJSX = firstNCollaborators.map((collaborator) =>
|
||||
renderCollaborator({
|
||||
actionManager,
|
||||
collaborator,
|
||||
socketId: collaborator.socketId,
|
||||
shouldWrapWithTooltip: true,
|
||||
isBeingFollowed: collaborator.socketId === userToFollow,
|
||||
}),
|
||||
);
|
||||
|
||||
return mobile ? (
|
||||
<div className={clsx("UserList UserList_mobile", className)}>
|
||||
{uniqueCollaboratorsArray.map(([clientId, collaborator]) =>
|
||||
{uniqueCollaboratorsArray.map((collaborator) =>
|
||||
renderCollaborator({
|
||||
actionManager,
|
||||
collaborator,
|
||||
clientId,
|
||||
socketId: collaborator.socketId,
|
||||
shouldWrapWithTooltip: true,
|
||||
isBeingFollowed: collaborator.socketId === userToFollow,
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={clsx("UserList", className)}>
|
||||
{firstNAvatarsJSX}
|
||||
<div className="UserList-wrapper" ref={userListWrapper}>
|
||||
<div
|
||||
className={clsx("UserList", className)}
|
||||
style={{ [`--max-avatars` as any]: maxAvatars }}
|
||||
>
|
||||
{firstNAvatarsJSX}
|
||||
|
||||
{uniqueCollaboratorsArray.length > FIRST_N_AVATARS && (
|
||||
<Popover.Root
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setSearchTerm("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Popover.Trigger className="UserList__more">
|
||||
+{uniqueCollaboratorsArray.length - FIRST_N_AVATARS}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
style={{
|
||||
zIndex: 2,
|
||||
width: "13rem",
|
||||
textAlign: "left",
|
||||
{uniqueCollaboratorsArray.length > maxAvatars - 1 && (
|
||||
<Popover.Root
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setSearchTerm("");
|
||||
}
|
||||
}}
|
||||
align="end"
|
||||
sideOffset={10}
|
||||
>
|
||||
<Island style={{ overflow: "hidden" }}>
|
||||
{uniqueCollaboratorsArray.length >=
|
||||
SHOW_COLLABORATORS_FILTER_AT && (
|
||||
<div className="UserList__search-wrapper">
|
||||
{searchIcon}
|
||||
<input
|
||||
className="UserList__search"
|
||||
type="text"
|
||||
placeholder={t("userList.search.placeholder")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="dropdown-menu UserList__collaborators">
|
||||
{filteredCollaborators.length === 0 && (
|
||||
<div className="UserList__collaborators__empty">
|
||||
{t("userList.search.empty")}
|
||||
<Popover.Trigger className="UserList__more">
|
||||
+{uniqueCollaboratorsArray.length - maxAvatars + 1}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
style={{
|
||||
zIndex: 2,
|
||||
width: "15rem",
|
||||
textAlign: "left",
|
||||
}}
|
||||
align="end"
|
||||
sideOffset={10}
|
||||
>
|
||||
<Island style={{ overflow: "hidden" }}>
|
||||
{uniqueCollaboratorsArray.length >=
|
||||
SHOW_COLLABORATORS_FILTER_AT && (
|
||||
<div className="UserList__search-wrapper">
|
||||
{searchIcon}
|
||||
<input
|
||||
className="UserList__search"
|
||||
type="text"
|
||||
placeholder={t("userList.search.placeholder")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="UserList__hint">
|
||||
{t("userList.hint.text")}
|
||||
<div className="dropdown-menu UserList__collaborators">
|
||||
{filteredCollaborators.length === 0 && (
|
||||
<div className="UserList__collaborators__empty">
|
||||
{t("userList.search.empty")}
|
||||
</div>
|
||||
)}
|
||||
<div className="UserList__hint">
|
||||
{t("userList.hint.text")}
|
||||
</div>
|
||||
{filteredCollaborators.map((collaborator) =>
|
||||
renderCollaborator({
|
||||
actionManager,
|
||||
collaborator,
|
||||
socketId: collaborator.socketId,
|
||||
withName: true,
|
||||
isBeingFollowed: collaborator.socketId === userToFollow,
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
{filteredCollaborators.map(([clientId, collaborator]) =>
|
||||
renderCollaborator({
|
||||
actionManager,
|
||||
collaborator,
|
||||
clientId,
|
||||
withName: true,
|
||||
isBeingFollowed: collaborator.socketId === userToFollow,
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
</Island>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
)}
|
||||
</Island>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
@ -236,10 +277,15 @@ export const UserList = React.memo(
|
|||
return false;
|
||||
}
|
||||
|
||||
const nextCollaboratorSocketIds = next.collaborators.keys();
|
||||
|
||||
for (const [socketId, collaborator] of prev.collaborators) {
|
||||
const nextCollaborator = next.collaborators.get(socketId);
|
||||
if (
|
||||
!nextCollaborator ||
|
||||
// this checks order of collaborators in the map is the same
|
||||
// as previous render
|
||||
socketId !== nextCollaboratorSocketIds.next().value ||
|
||||
!isShallowEqual(
|
||||
collaborator,
|
||||
nextCollaborator,
|
||||
|
|
|
@ -66,42 +66,46 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const cursorButton: {
|
||||
[id: string]: string | undefined;
|
||||
} = {};
|
||||
const pointerViewportCoords: InteractiveCanvasRenderConfig["remotePointerViewportCoords"] =
|
||||
{};
|
||||
const remotePointerButton: InteractiveCanvasRenderConfig["remotePointerButton"] =
|
||||
new Map();
|
||||
const remotePointerViewportCoords: InteractiveCanvasRenderConfig["remotePointerViewportCoords"] =
|
||||
new Map();
|
||||
const remoteSelectedElementIds: InteractiveCanvasRenderConfig["remoteSelectedElementIds"] =
|
||||
{};
|
||||
const pointerUsernames: { [id: string]: string } = {};
|
||||
const pointerUserStates: { [id: string]: string } = {};
|
||||
new Map();
|
||||
const remotePointerUsernames: InteractiveCanvasRenderConfig["remotePointerUsernames"] =
|
||||
new Map();
|
||||
const remotePointerUserStates: InteractiveCanvasRenderConfig["remotePointerUserStates"] =
|
||||
new Map();
|
||||
|
||||
props.appState.collaborators.forEach((user, socketId) => {
|
||||
if (user.selectedElementIds) {
|
||||
for (const id of Object.keys(user.selectedElementIds)) {
|
||||
if (!(id in remoteSelectedElementIds)) {
|
||||
remoteSelectedElementIds[id] = [];
|
||||
if (!remoteSelectedElementIds.has(id)) {
|
||||
remoteSelectedElementIds.set(id, []);
|
||||
}
|
||||
remoteSelectedElementIds[id].push(socketId);
|
||||
remoteSelectedElementIds.get(id)!.push(socketId);
|
||||
}
|
||||
}
|
||||
if (!user.pointer) {
|
||||
return;
|
||||
}
|
||||
if (user.username) {
|
||||
pointerUsernames[socketId] = user.username;
|
||||
remotePointerUsernames.set(socketId, user.username);
|
||||
}
|
||||
if (user.userState) {
|
||||
pointerUserStates[socketId] = user.userState;
|
||||
remotePointerUserStates.set(socketId, user.userState);
|
||||
}
|
||||
pointerViewportCoords[socketId] = sceneCoordsToViewportCoords(
|
||||
{
|
||||
sceneX: user.pointer.x,
|
||||
sceneY: user.pointer.y,
|
||||
},
|
||||
props.appState,
|
||||
remotePointerViewportCoords.set(
|
||||
socketId,
|
||||
sceneCoordsToViewportCoords(
|
||||
{
|
||||
sceneX: user.pointer.x,
|
||||
sceneY: user.pointer.y,
|
||||
},
|
||||
props.appState,
|
||||
),
|
||||
);
|
||||
cursorButton[socketId] = user.button;
|
||||
remotePointerButton.set(socketId, user.button);
|
||||
});
|
||||
|
||||
const selectionColor =
|
||||
|
@ -120,11 +124,11 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
|
|||
scale: window.devicePixelRatio,
|
||||
appState: props.appState,
|
||||
renderConfig: {
|
||||
remotePointerViewportCoords: pointerViewportCoords,
|
||||
remotePointerButton: cursorButton,
|
||||
remotePointerViewportCoords,
|
||||
remotePointerButton,
|
||||
remoteSelectedElementIds,
|
||||
remotePointerUsernames: pointerUsernames,
|
||||
remotePointerUserStates: pointerUserStates,
|
||||
remotePointerUsernames,
|
||||
remotePointerUserStates,
|
||||
selectionColor,
|
||||
renderScrollbars: false,
|
||||
},
|
||||
|
|
|
@ -1798,7 +1798,7 @@ export const fullscreenIcon = 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 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" />
|
||||
|
@ -1837,3 +1837,26 @@ export const searchIcon = createIcon(
|
|||
</g>,
|
||||
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 = () =>
|
||||
(navigator as any).brave?.isBrave?.name === "isBrave";
|
||||
|
||||
export const supportsResizeObserver =
|
||||
typeof window !== "undefined" && "ResizeObserver" in window;
|
||||
|
||||
export const APP_NAME = "Excalidraw";
|
||||
|
||||
export const DRAGGING_THRESHOLD = 10; // px
|
||||
|
@ -144,6 +147,11 @@ export const DEFAULT_VERTICAL_ALIGN = "top";
|
|||
export const DEFAULT_VERSION = "{version}";
|
||||
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 GRID_SIZE = 20; // TODO make it configurable?
|
||||
|
|
|
@ -116,8 +116,8 @@
|
|||
}
|
||||
|
||||
@mixin avatarStyles {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
width: var(--avatar-size, 1.5rem);
|
||||
height: var(--avatar-size, 1.5rem);
|
||||
position: relative;
|
||||
border-radius: 100%;
|
||||
outline-offset: 2px;
|
||||
|
@ -131,6 +131,10 @@
|
|||
color: var(--color-gray-90);
|
||||
flex: 0 0 auto;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
&-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
@ -144,14 +148,14 @@
|
|||
right: -3px;
|
||||
bottom: -3px;
|
||||
left: -3px;
|
||||
border: 1px solid var(--avatar-border-color);
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
&--is-followed::before {
|
||||
&.is-followed::before {
|
||||
border-color: var(--color-primary-hover);
|
||||
box-shadow: 0 0 0 1px var(--color-primary-hover);
|
||||
}
|
||||
&--is-current-user {
|
||||
&.is-current-user {
|
||||
cursor: auto;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -84,7 +84,7 @@ export class LaserTrails implements Trail {
|
|||
if (!this.collabTrails.has(key)) {
|
||||
trail = new AnimatedTrail(this.animationFrameHandler, this.app, {
|
||||
...this.getTrailOptions(),
|
||||
fill: () => getClientColor(key),
|
||||
fill: () => getClientColor(key, collabolator),
|
||||
});
|
||||
trail.start(this.container);
|
||||
|
||||
|
|
|
@ -534,7 +534,10 @@
|
|||
},
|
||||
"hint": {
|
||||
"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";
|
||||
|
||||
import { renderSelectionElement } from "../renderer/renderElement";
|
||||
import { getClientColor } from "../clients";
|
||||
import { getClientColor, renderRemoteCursors } from "../clients";
|
||||
import {
|
||||
isSelectedViaGroup,
|
||||
getSelectedGroupIds,
|
||||
|
@ -29,7 +29,7 @@ import {
|
|||
TransformHandleType,
|
||||
} from "../element/transformHandles";
|
||||
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 { renderSnaps } from "../renderer/renderSnaps";
|
||||
|
@ -726,14 +726,18 @@ const _renderInteractiveScene = ({
|
|||
selectionColors.push(selectionColor);
|
||||
}
|
||||
// remote users
|
||||
if (renderConfig.remoteSelectedElementIds[element.id]) {
|
||||
const remoteClients = renderConfig.remoteSelectedElementIds.get(
|
||||
element.id,
|
||||
);
|
||||
if (remoteClients) {
|
||||
selectionColors.push(
|
||||
...renderConfig.remoteSelectedElementIds[element.id].map(
|
||||
(socketId: string) => {
|
||||
const background = getClientColor(socketId);
|
||||
return background;
|
||||
},
|
||||
),
|
||||
...remoteClients.map((socketId) => {
|
||||
const background = getClientColor(
|
||||
socketId,
|
||||
appState.collaborators.get(socketId),
|
||||
);
|
||||
return background;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -747,7 +751,7 @@ const _renderInteractiveScene = ({
|
|||
elementX2,
|
||||
elementY2,
|
||||
selectionColors,
|
||||
dashed: !!renderConfig.remoteSelectedElementIds[element.id],
|
||||
dashed: !!remoteClients,
|
||||
cx,
|
||||
cy,
|
||||
activeEmbeddable:
|
||||
|
@ -858,143 +862,13 @@ const _renderInteractiveScene = ({
|
|||
// Reset zoom
|
||||
context.restore();
|
||||
|
||||
// Paint remote pointers
|
||||
for (const clientId in renderConfig.remotePointerViewportCoords) {
|
||||
let { x, y } = renderConfig.remotePointerViewportCoords[clientId];
|
||||
|
||||
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(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();
|
||||
}
|
||||
renderRemoteCursors({
|
||||
context,
|
||||
renderConfig,
|
||||
appState,
|
||||
normalizedWidth,
|
||||
normalizedHeight,
|
||||
});
|
||||
|
||||
// Paint scrollbars
|
||||
let scrollBars;
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import type { RoughCanvas } from "roughjs/bin/canvas";
|
||||
import { Drawable } from "roughjs/bin/core";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
NonDeletedElementsMap,
|
||||
NonDeletedExcalidrawElement,
|
||||
|
@ -13,6 +14,8 @@ import {
|
|||
ElementsPendingErasure,
|
||||
InteractiveCanvasAppState,
|
||||
StaticCanvasAppState,
|
||||
SocketId,
|
||||
UserIdleState,
|
||||
} from "../types";
|
||||
import { MakeBrand } from "../utility-types";
|
||||
|
||||
|
@ -46,11 +49,11 @@ export type SVGRenderConfig = {
|
|||
export type InteractiveCanvasRenderConfig = {
|
||||
// collab-related state
|
||||
// ---------------------------------------------------------------------------
|
||||
remoteSelectedElementIds: { [elementId: string]: string[] };
|
||||
remotePointerViewportCoords: { [id: string]: { x: number; y: number } };
|
||||
remotePointerUserStates: { [id: string]: string };
|
||||
remotePointerUsernames: { [id: string]: string };
|
||||
remotePointerButton?: { [id: string]: string | undefined };
|
||||
remoteSelectedElementIds: Map<ExcalidrawElement["id"], SocketId[]>;
|
||||
remotePointerViewportCoords: Map<SocketId, { x: number; y: number }>;
|
||||
remotePointerUserStates: Map<SocketId, UserIdleState>;
|
||||
remotePointerUsernames: Map<SocketId, string>;
|
||||
remotePointerButton: Map<SocketId, string | undefined>;
|
||||
selectionColor?: string;
|
||||
// extra options passed to the renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
@ -61,6 +61,9 @@ export type Collaborator = Readonly<{
|
|||
id?: string;
|
||||
socketId?: SocketId;
|
||||
isCurrentUser?: boolean;
|
||||
isInCall?: boolean;
|
||||
isSpeaking?: boolean;
|
||||
isMuted?: boolean;
|
||||
}>;
|
||||
|
||||
export type CollaboratorPointer = {
|
||||
|
@ -319,9 +322,9 @@ export interface AppState {
|
|||
y: number;
|
||||
} | null;
|
||||
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;
|
||||
/** the clientIds of the users following the current user */
|
||||
/** the socket ids of the users following the current user */
|
||||
followedBy: Set<SocketId>;
|
||||
}
|
||||
|
||||
|
|
|
@ -791,6 +791,14 @@ export const isShallowEqual = <
|
|||
const aKeys = Object.keys(objA);
|
||||
const bKeys = Object.keys(objB);
|
||||
if (aKeys.length !== bKeys.length) {
|
||||
if (debug) {
|
||||
console.warn(
|
||||
`%cisShallowEqual: objects don't have same properties ->`,
|
||||
"color: #8B4000",
|
||||
objA,
|
||||
objB,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue