From 068895db0eed082505788a2db0c6d63664e857df Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Mon, 18 Mar 2024 10:20:07 +0100 Subject: [PATCH] feat: expose more collaborator status icons (#7777) --- excalidraw-app/index.scss | 2 +- .../excalidraw/actions/actionNavigate.tsx | 92 +++++-- packages/excalidraw/clients.ts | 229 +++++++++++++++++- packages/excalidraw/components/App.tsx | 4 +- packages/excalidraw/components/Avatar.tsx | 15 +- packages/excalidraw/components/LayerUI.scss | 7 + packages/excalidraw/components/UserList.scss | 108 +++++++-- packages/excalidraw/components/UserList.tsx | 228 ++++++++++------- .../components/canvases/InteractiveCanvas.tsx | 52 ++-- packages/excalidraw/components/icons.tsx | 25 +- packages/excalidraw/constants.ts | 8 + packages/excalidraw/css/variables.module.scss | 14 +- packages/excalidraw/laser-trails.ts | 2 +- packages/excalidraw/locales/en.json | 5 +- .../excalidraw/renderer/interactiveScene.ts | 168 ++----------- packages/excalidraw/scene/types.ts | 13 +- packages/excalidraw/types.ts | 7 +- packages/excalidraw/utils.ts | 8 + 18 files changed, 652 insertions(+), 335 deletions(-) diff --git a/excalidraw-app/index.scss b/excalidraw-app/index.scss index 021442753..24741b062 100644 --- a/excalidraw-app/index.scss +++ b/excalidraw-app/index.scss @@ -8,7 +8,7 @@ .top-right-ui { display: flex; justify-content: center; - align-items: center; + align-items: flex-start; } .footer-center { diff --git a/packages/excalidraw/actions/actionNavigate.tsx b/packages/excalidraw/actions/actionNavigate.tsx index ea65584fe..5c60a029d 100644 --- a/packages/excalidraw/actions/actionNavigate.tsx +++ b/packages/excalidraw/actions/actionNavigate.tsx @@ -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 ? ( +
+
+
+
+
+ ) : collaborator.isMuted ? ( +
+ {microphoneMutedIcon} +
+ ) : ( +
{microphoneIcon}
+ ) + ) : null; return withName ? (
updateData(collaborator)} > {}} name={collaborator.username || ""} src={collaborator.avatarUrl} - isBeingFollowed={isBeingFollowed} - isCurrentUser={collaborator.isCurrentUser === true} + className={statusClassNames} />
{collaborator.username}
-
- {eyeIcon} +
+ {isBeingFollowed && ( +
+ {eyeIcon} +
+ )} + {statusIconJSX}
) : ( - { - updateData(collaborator); - }} - name={collaborator.username || ""} - src={collaborator.avatarUrl} - isBeingFollowed={isBeingFollowed} - isCurrentUser={collaborator.isCurrentUser === true} - /> +
+ { + updateData(collaborator); + }} + name={collaborator.username || ""} + src={collaborator.avatarUrl} + className={statusClassNames} + /> + {statusIconJSX && ( +
+ {statusIconJSX} +
+ )} +
); }, }); diff --git a/packages/excalidraw/clients.ts b/packages/excalidraw/clients.ts index 354098918..439080bd5 100644 --- a/packages/excalidraw/clients.ts +++ b/packages/excalidraw/clients.ts @@ -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(); + } +}; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 97ce14662..b02d919d4 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -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; diff --git a/packages/excalidraw/components/Avatar.tsx b/packages/excalidraw/components/Avatar.tsx index b7b1bf962..9ddc319c6 100644 --- a/packages/excalidraw/components/Avatar.tsx +++ b/packages/excalidraw/components/Avatar.tsx @@ -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 ( -
+
{loadImg ? ( * { + pointer-events: var(--ui-pointerEvents); + } } &__footer { diff --git a/packages/excalidraw/components/UserList.scss b/packages/excalidraw/components/UserList.scss index fceb1e7c4..86c3179ad 100644 --- a/packages/excalidraw/components/UserList.scss +++ b/packages/excalidraw/components/UserList.scss @@ -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); diff --git a/packages/excalidraw/components/UserList.tsx b/packages/excalidraw/components/UserList.tsx index ba01b52dc..ced759333 100644 --- a/packages/excalidraw/components/UserList.tsx +++ b/packages/excalidraw/components/UserList.tsx @@ -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 ? ( - - {children} - + {children} ) : ( - {children} + {children} ); 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 ( @@ -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(); + const uniqueCollaboratorsMap = new Map< + ClientId, + MarkRequired + >(); 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(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 ? (
- {uniqueCollaboratorsArray.map(([clientId, collaborator]) => + {uniqueCollaboratorsArray.map((collaborator) => renderCollaborator({ actionManager, collaborator, - clientId, + socketId: collaborator.socketId, shouldWrapWithTooltip: true, isBeingFollowed: collaborator.socketId === userToFollow, }), )}
) : ( -
- {firstNAvatarsJSX} +
+
+ {firstNAvatarsJSX} - {uniqueCollaboratorsArray.length > FIRST_N_AVATARS && ( - { - if (!isOpen) { - setSearchTerm(""); - } - }} - > - - +{uniqueCollaboratorsArray.length - FIRST_N_AVATARS} - - maxAvatars - 1 && ( + { + if (!isOpen) { + setSearchTerm(""); + } }} - align="end" - sideOffset={10} > - - {uniqueCollaboratorsArray.length >= - SHOW_COLLABORATORS_FILTER_AT && ( -
- {searchIcon} - { - setSearchTerm(e.target.value); - }} - /> -
- )} -
- {filteredCollaborators.length === 0 && ( -
- {t("userList.search.empty")} + + +{uniqueCollaboratorsArray.length - maxAvatars + 1} + + + + {uniqueCollaboratorsArray.length >= + SHOW_COLLABORATORS_FILTER_AT && ( +
+ {searchIcon} + { + setSearchTerm(e.target.value); + }} + />
)} -
- {t("userList.hint.text")} +
+ {filteredCollaborators.length === 0 && ( +
+ {t("userList.search.empty")} +
+ )} +
+ {t("userList.hint.text")} +
+ {filteredCollaborators.map((collaborator) => + renderCollaborator({ + actionManager, + collaborator, + socketId: collaborator.socketId, + withName: true, + isBeingFollowed: collaborator.socketId === userToFollow, + }), + )}
- {filteredCollaborators.map(([clientId, collaborator]) => - renderCollaborator({ - actionManager, - collaborator, - clientId, - withName: true, - isBeingFollowed: collaborator.socketId === userToFollow, - }), - )} -
-
-
- - )} + + + + )} +
); }, @@ -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, diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index e76d8ae68..163756d57 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -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, }, diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index 967ae1976..063253f69 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -1798,7 +1798,7 @@ export const fullscreenIcon = createIcon( ); export const eyeIcon = createIcon( - + @@ -1837,3 +1837,26 @@ export const searchIcon = createIcon( , tablerIconProps, ); + +export const microphoneIcon = createIcon( + + + + + + + , + tablerIconProps, +); + +export const microphoneMutedIcon = createIcon( + + + + + + + + , + tablerIconProps, +); diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index 09e497564..ad87cb9e1 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -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? diff --git a/packages/excalidraw/css/variables.module.scss b/packages/excalidraw/css/variables.module.scss index 247e3f840..71097ba3e 100644 --- a/packages/excalidraw/css/variables.module.scss +++ b/packages/excalidraw/css/variables.module.scss @@ -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; } } diff --git a/packages/excalidraw/laser-trails.ts b/packages/excalidraw/laser-trails.ts index 49a0de5be..a58efddef 100644 --- a/packages/excalidraw/laser-trails.ts +++ b/packages/excalidraw/laser-trails.ts @@ -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); diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index ac9108a32..1213bc318 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -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" } } } diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index a6d997770..0fd814e89 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -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; diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index 02aa3b7bf..63a49fec5 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -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; + remotePointerViewportCoords: Map; + remotePointerUserStates: Map; + remotePointerUsernames: Map; + remotePointerButton: Map; selectionColor?: string; // extra options passed to the renderer // --------------------------------------------------------------------------- diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index fefb82c2c..2729bc037 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -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; } diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index d27445dfa..493dce340 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -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; }