diff --git a/excalidraw-app/app_constants.ts b/excalidraw-app/app_constants.ts index 179fe52e..a20a2350 100644 --- a/excalidraw-app/app_constants.ts +++ b/excalidraw-app/app_constants.ts @@ -15,11 +15,14 @@ export const FILE_CACHE_MAX_AGE_SEC = 31536000; export const WS_EVENTS = { SERVER_VOLATILE: "server-volatile-broadcast", SERVER: "server-broadcast", -}; + USER_FOLLOW_CHANGE: "user-follow", + USER_FOLLOW_ROOM_CHANGE: "user-follow-room-change", +} as const; -export enum WS_SCENE_EVENT_TYPES { +export enum WS_SUBTYPES { INIT = "SCENE_INIT", UPDATE = "SCENE_UPDATE", + USER_VIEWPORT_BOUNDS = "USER_VIEWPORT_BOUNDS", } export const FIREBASE_STORAGE_PREFIXES = { diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx index 6ecdd157..99fc4361 100644 --- a/excalidraw-app/collab/Collab.tsx +++ b/excalidraw-app/collab/Collab.tsx @@ -1,6 +1,9 @@ import throttle from "lodash.throttle"; import { PureComponent } from "react"; -import { ExcalidrawImperativeAPI } from "../../packages/excalidraw/types"; +import { + ExcalidrawImperativeAPI, + SocketId, +} from "../../packages/excalidraw/types"; import { ErrorDialog } from "../../packages/excalidraw/components/ErrorDialog"; import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants"; import { ImportedDataState } from "../../packages/excalidraw/data/types"; @@ -11,11 +14,14 @@ import { import { getSceneVersion, restoreElements, + zoomToFitBounds, } from "../../packages/excalidraw/index"; import { Collaborator, Gesture } from "../../packages/excalidraw/types"; import { preventUnload, resolvablePromise, + throttleRAF, + viewportCoordsToSceneCoords, withBatchedUpdates, } from "../../packages/excalidraw/utils"; import { @@ -24,8 +30,9 @@ import { FIREBASE_STORAGE_PREFIXES, INITIAL_SCENE_UPDATE_TIMEOUT, LOAD_IMAGES_TIMEOUT, - WS_SCENE_EVENT_TYPES, + WS_SUBTYPES, SYNC_FULL_SCENE_INTERVAL_MS, + WS_EVENTS, } from "../app_constants"; import { generateCollaborationLinkData, @@ -74,6 +81,7 @@ import { resetBrowserStateVersions } from "../data/tabSync"; import { LocalData } from "../data/LocalData"; import { atom, useAtom } from "jotai"; import { appJotaiStore } from "../app-jotai"; +import { Mutable } from "../../packages/excalidraw/utility-types"; export const collabAPIAtom = atom(null); export const collabDialogShownAtom = atom(false); @@ -154,12 +162,28 @@ class Collab extends PureComponent { this.idleTimeoutId = null; } + private onUmmount: (() => void) | null = null; + componentDidMount() { window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload); window.addEventListener("online", this.onOfflineStatusToggle); window.addEventListener("offline", this.onOfflineStatusToggle); window.addEventListener(EVENT.UNLOAD, this.onUnload); + const unsubOnUserFollow = this.excalidrawAPI.onUserFollow((payload) => { + this.portal.socket && this.portal.broadcastUserFollowed(payload); + }); + const throttledRelayUserViewportBounds = throttleRAF( + this.relayUserViewportBounds, + ); + const unsubOnScrollChange = this.excalidrawAPI.onScrollChange(() => + throttledRelayUserViewportBounds(), + ); + this.onUmmount = () => { + unsubOnUserFollow(); + unsubOnScrollChange(); + }; + this.onOfflineStatusToggle(); const collabAPI: CollabAPI = { @@ -207,6 +231,7 @@ class Collab extends PureComponent { window.clearTimeout(this.idleTimeoutId); this.idleTimeoutId = null; } + this.onUmmount?.(); } isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!; @@ -489,7 +514,7 @@ class Collab extends PureComponent { switch (decryptedData.type) { case "INVALID_RESPONSE": return; - case WS_SCENE_EVENT_TYPES.INIT: { + case WS_SUBTYPES.INIT: { if (!this.portal.socketInitialized) { this.initializeRoom({ fetchScene: false }); const remoteElements = decryptedData.payload.elements; @@ -505,7 +530,7 @@ class Collab extends PureComponent { } break; } - case WS_SCENE_EVENT_TYPES.UPDATE: + case WS_SUBTYPES.UPDATE: this.handleRemoteSceneUpdate( this.reconcileElements(decryptedData.payload.elements), ); @@ -513,31 +538,61 @@ class Collab extends PureComponent { case "MOUSE_LOCATION": { const { pointer, button, username, selectedElementIds } = decryptedData.payload; + const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] = decryptedData.payload.socketId || // @ts-ignore legacy, see #2094 (#2097) decryptedData.payload.socketID; - const collaborators = new Map(this.collaborators); - const user = collaborators.get(socketId) || {}!; - user.pointer = pointer; - user.button = button; - user.selectedElementIds = selectedElementIds; - user.username = username; - collaborators.set(socketId, user); - this.excalidrawAPI.updateScene({ - collaborators, + this.updateCollaborator(socketId, { + pointer, + button, + selectedElementIds, + username, }); + break; } + + case WS_SUBTYPES.USER_VIEWPORT_BOUNDS: { + const { bounds, socketId } = decryptedData.payload; + + const appState = this.excalidrawAPI.getAppState(); + + // we're not following the user + // (shouldn't happen, but could be late message or bug upstream) + if (appState.userToFollow?.socketId !== socketId) { + console.warn( + `receiving remote client's (from ${socketId}) viewport bounds even though we're not subscribed to it!`, + ); + return; + } + + // cross-follow case, ignore updates in this case + if ( + appState.userToFollow && + appState.followedBy.has(appState.userToFollow.socketId) + ) { + return; + } + + this.excalidrawAPI.updateScene({ + appState: zoomToFitBounds({ + appState, + bounds, + fitToViewport: true, + viewportZoomFactor: 1, + }).appState, + }); + + break; + } + case "IDLE_STATUS": { const { userState, socketId, username } = decryptedData.payload; - const collaborators = new Map(this.collaborators); - const user = collaborators.get(socketId) || {}!; - user.userState = userState; - user.username = username; - this.excalidrawAPI.updateScene({ - collaborators, + this.updateCollaborator(socketId, { + userState, + username, }); break; } @@ -556,6 +611,17 @@ class Collab extends PureComponent { scenePromise.resolve(sceneData); }); + this.portal.socket.on( + WS_EVENTS.USER_FOLLOW_ROOM_CHANGE, + (followedBy: string[]) => { + this.excalidrawAPI.updateScene({ + appState: { followedBy: new Set(followedBy) }, + }); + + this.relayUserViewportBounds({ shouldPerform: true }); + }, + ); + this.initializeIdleDetector(); this.setState({ @@ -738,6 +804,24 @@ class Collab extends PureComponent { this.excalidrawAPI.updateScene({ collaborators }); } + private updateCollaborator = ( + socketId: SocketId, + updates: Partial, + ) => { + const collaborators = new Map(this.collaborators); + const user: Mutable = Object.assign( + {}, + collaborators.get(socketId), + updates, + ); + collaborators.set(socketId, user); + this.collaborators = collaborators; + + this.excalidrawAPI.updateScene({ + collaborators, + }); + }; + public setLastBroadcastedOrReceivedSceneVersion = (version: number) => { this.lastBroadcastedOrReceivedSceneVersion = version; }; @@ -763,6 +847,30 @@ class Collab extends PureComponent { CURSOR_SYNC_TIMEOUT, ); + relayUserViewportBounds = (props?: { shouldPerform: boolean }) => { + const appState = this.excalidrawAPI.getAppState(); + + if ( + this.portal.socket && + (appState.followedBy.size > 0 || props?.shouldPerform) + ) { + const { x: x1, y: y1 } = viewportCoordsToSceneCoords( + { clientX: 0, clientY: 0 }, + appState, + ); + + const { x: x2, y: y2 } = viewportCoordsToSceneCoords( + { clientX: appState.width, clientY: appState.height }, + appState, + ); + + this.portal.broadcastUserViewportBounds( + { bounds: [x1, y1, x2, y2] }, + `follow_${this.portal.socket.id}`, + ); + } + }; + onIdleStateChange = (userState: UserIdleState) => { this.portal.broadcastIdleChange(userState); }; @@ -772,7 +880,7 @@ class Collab extends PureComponent { getSceneVersion(elements) > this.getLastBroadcastedOrReceivedSceneVersion() ) { - this.portal.broadcastScene(WS_SCENE_EVENT_TYPES.UPDATE, elements, false); + this.portal.broadcastScene(WS_SUBTYPES.UPDATE, elements, false); this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements); this.queueBroadcastAllElements(); } @@ -785,7 +893,7 @@ class Collab extends PureComponent { queueBroadcastAllElements = throttle(() => { this.portal.broadcastScene( - WS_SCENE_EVENT_TYPES.UPDATE, + WS_SUBTYPES.UPDATE, this.excalidrawAPI.getSceneElementsIncludingDeleted(), true, ); diff --git a/excalidraw-app/collab/Portal.tsx b/excalidraw-app/collab/Portal.tsx index 4e505432..7486486c 100644 --- a/excalidraw-app/collab/Portal.tsx +++ b/excalidraw-app/collab/Portal.tsx @@ -7,12 +7,11 @@ import { import { TCollabClass } from "./Collab"; import { ExcalidrawElement } from "../../packages/excalidraw/element/types"; +import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants"; import { - WS_EVENTS, - FILE_UPLOAD_TIMEOUT, - WS_SCENE_EVENT_TYPES, -} from "../app_constants"; -import { UserIdleState } from "../../packages/excalidraw/types"; + OnUserFollowedPayload, + UserIdleState, +} from "../../packages/excalidraw/types"; import { trackEvent } from "../../packages/excalidraw/analytics"; import throttle from "lodash.throttle"; import { newElementWith } from "../../packages/excalidraw/element/mutateElement"; @@ -46,7 +45,7 @@ class Portal { }); this.socket.on("new-user", async (_socketId: string) => { this.broadcastScene( - WS_SCENE_EVENT_TYPES.INIT, + WS_SUBTYPES.INIT, this.collab.getSceneElementsIncludingDeleted(), /* syncAll */ true, ); @@ -83,6 +82,7 @@ class Portal { async _broadcastSocketData( data: SocketUpdateData, volatile: boolean = false, + roomId?: string, ) { if (this.isOpen()) { const json = JSON.stringify(data); @@ -91,7 +91,7 @@ class Portal { this.socket?.emit( volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER, - this.roomId, + roomId ?? this.roomId, encryptedBuffer, iv, ); @@ -130,11 +130,11 @@ class Portal { }, FILE_UPLOAD_TIMEOUT); broadcastScene = async ( - updateType: WS_SCENE_EVENT_TYPES.INIT | WS_SCENE_EVENT_TYPES.UPDATE, + updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE, allElements: readonly ExcalidrawElement[], syncAll: boolean, ) => { - if (updateType === WS_SCENE_EVENT_TYPES.INIT && !syncAll) { + if (updateType === WS_SUBTYPES.INIT && !syncAll) { throw new Error("syncAll must be true when sending SCENE.INIT"); } @@ -213,12 +213,43 @@ class Portal { username: this.collab.state.username, }, }; + return this._broadcastSocketData( data as SocketUpdateData, true, // volatile ); } }; + + broadcastUserViewportBounds = ( + payload: { + bounds: [number, number, number, number]; + }, + roomId: string, + ) => { + if (this.socket?.id) { + const data: SocketUpdateDataSource["USER_VIEWPORT_BOUNDS"] = { + type: WS_SUBTYPES.USER_VIEWPORT_BOUNDS, + payload: { + socketId: this.socket.id, + username: this.collab.state.username, + bounds: payload.bounds, + }, + }; + + return this._broadcastSocketData( + data as SocketUpdateData, + true, // volatile + roomId, + ); + } + }; + + broadcastUserFollowed = (payload: OnUserFollowedPayload) => { + if (this.socket?.id) { + this.socket.emit(WS_EVENTS.USER_FOLLOW_CHANGE, payload); + } + }; } export default Portal; diff --git a/excalidraw-app/data/index.ts b/excalidraw-app/data/index.ts index 6bab9833..b162da9a 100644 --- a/excalidraw-app/data/index.ts +++ b/excalidraw-app/data/index.ts @@ -119,6 +119,14 @@ export type SocketUpdateDataSource = { username: string; }; }; + USER_VIEWPORT_BOUNDS: { + type: "USER_VIEWPORT_BOUNDS"; + payload: { + socketId: string; + username: string; + bounds: [number, number, number, number]; + }; + }; IDLE_STATUS: { type: "IDLE_STATUS"; payload: { diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index f61f57db..7d57c64a 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -109,6 +109,7 @@ export const actionZoomIn = register({ }, appState, ), + userToFollow: null, }, commitToHistory: false, }; @@ -146,6 +147,7 @@ export const actionZoomOut = register({ }, appState, ), + userToFollow: null, }, commitToHistory: false, }; @@ -183,6 +185,7 @@ export const actionResetZoom = register({ }, appState, ), + userToFollow: null, }, commitToHistory: false, }; @@ -226,22 +229,20 @@ const zoomValueToFitBoundsOnViewport = ( return clampedZoomValueToFitElements as NormalizedZoomValue; }; -export const zoomToFit = ({ - targetElements, +export const zoomToFitBounds = ({ + bounds, appState, fitToViewport = false, viewportZoomFactor = 0.7, }: { - targetElements: readonly ExcalidrawElement[]; + bounds: readonly [number, number, number, number]; appState: Readonly; /** whether to fit content to viewport (beyond >100%) */ fitToViewport: boolean; /** zoom content to cover X of the viewport, when fitToViewport=true */ viewportZoomFactor?: number; }) => { - const commonBounds = getCommonBounds(getNonDeletedElements(targetElements)); - - const [x1, y1, x2, y2] = commonBounds; + const [x1, y1, x2, y2] = bounds; const centerX = (x1 + x2) / 2; const centerY = (y1 + y2) / 2; @@ -282,7 +283,7 @@ export const zoomToFit = ({ scrollX = (appStateWidth / 2) * (1 / newZoomValue) - centerX; scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY; } else { - newZoomValue = zoomValueToFitBoundsOnViewport(commonBounds, { + newZoomValue = zoomValueToFitBoundsOnViewport(bounds, { width: appState.width, height: appState.height, }); @@ -311,6 +312,29 @@ export const zoomToFit = ({ }; }; +export const zoomToFit = ({ + targetElements, + appState, + fitToViewport, + viewportZoomFactor, +}: { + targetElements: readonly ExcalidrawElement[]; + appState: Readonly; + /** whether to fit content to viewport (beyond >100%) */ + fitToViewport: boolean; + /** zoom content to cover X of the viewport, when fitToViewport=true */ + viewportZoomFactor?: number; +}) => { + const commonBounds = getCommonBounds(getNonDeletedElements(targetElements)); + + return zoomToFitBounds({ + bounds: commonBounds, + appState, + fitToViewport, + viewportZoomFactor, + }); +}; + // Note, this action differs from actionZoomToFitSelection in that it doesn't // zoom beyond 100%. In other words, if the content is smaller than viewport // size, it won't be zoomed in. @@ -321,7 +345,10 @@ export const actionZoomToFitSelectionInViewport = register({ const selectedElements = app.scene.getSelectedElements(appState); return zoomToFit({ targetElements: selectedElements.length ? selectedElements : elements, - appState, + appState: { + ...appState, + userToFollow: null, + }, fitToViewport: false, }); }, @@ -341,7 +368,10 @@ export const actionZoomToFitSelection = register({ const selectedElements = app.scene.getSelectedElements(appState); return zoomToFit({ targetElements: selectedElements.length ? selectedElements : elements, - appState, + appState: { + ...appState, + userToFollow: null, + }, fitToViewport: true, }); }, @@ -358,7 +388,14 @@ export const actionZoomToFit = register({ viewMode: true, trackEvent: { category: "canvas" }, perform: (elements, appState) => - zoomToFit({ targetElements: elements, appState, fitToViewport: false }), + zoomToFit({ + targetElements: elements, + appState: { + ...appState, + userToFollow: null, + }, + fitToViewport: false, + }), keyTest: (event) => event.code === CODES.ONE && event.shiftKey && diff --git a/packages/excalidraw/actions/actionNavigate.tsx b/packages/excalidraw/actions/actionNavigate.tsx index 126e547a..11dc2212 100644 --- a/packages/excalidraw/actions/actionNavigate.tsx +++ b/packages/excalidraw/actions/actionNavigate.tsx @@ -1,6 +1,5 @@ import { getClientColor } from "../clients"; import { Avatar } from "../components/Avatar"; -import { centerScrollOn } from "../scene/scroll"; import { Collaborator } from "../types"; import { register } from "./register"; @@ -9,39 +8,68 @@ export const actionGoToCollaborator = register({ viewMode: true, trackEvent: { category: "collab" }, perform: (_elements, appState, value) => { - const point = value as Collaborator["pointer"]; + const _value = value as Collaborator; + const point = _value.pointer; + if (!point) { return { appState, commitToHistory: false }; } + if (appState.userToFollow?.socketId === _value.socketId) { + return { + appState: { + ...appState, + userToFollow: null, + }, + commitToHistory: false, + }; + } + return { appState: { ...appState, - ...centerScrollOn({ - scenePoint: point, - viewportDimensions: { - width: appState.width, - height: appState.height, - }, - zoom: appState.zoom, - }), + userToFollow: { + socketId: _value.socketId!, + username: _value.username || "", + }, // Close mobile menu openMenu: appState.openMenu === "canvas" ? null : appState.openMenu, }, commitToHistory: false, }; }, - PanelComponent: ({ updateData, data }) => { - const [clientId, collaborator] = data as [string, Collaborator]; + PanelComponent: ({ updateData, data, appState }) => { + const [clientId, collaborator, withName] = data as [ + string, + Collaborator, + boolean, + ]; const background = getClientColor(clientId); - return ( + return withName ? ( +
updateData({ ...collaborator, clientId })} + > + {}} + name={collaborator.username || ""} + src={collaborator.avatarUrl} + isBeingFollowed={appState.userToFollow?.socketId === clientId} + /> + {collaborator.username} +
+ ) : ( updateData(collaborator.pointer)} + onClick={() => { + updateData({ ...collaborator, clientId }); + }} name={collaborator.username || ""} src={collaborator.avatarUrl} + isBeingFollowed={appState.userToFollow?.socketId === clientId} /> ); }, diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 0089f57e..4dec9a79 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -105,6 +105,8 @@ export const getDefaultAppState = (): Omit< y: 0, }, objectsSnapModeEnabled: false, + userToFollow: null, + followedBy: new Set(), }; }; @@ -215,6 +217,8 @@ const APP_STATE_STORAGE_CONF = (< snapLines: { browser: false, export: false, server: false }, originSnapOffset: { browser: false, export: false, server: false }, objectsSnapModeEnabled: { browser: true, export: false, server: false }, + userToFollow: { browser: false, export: false, server: false }, + followedBy: { browser: false, export: false, server: false }, }); const _clearAppStateForStorage = < diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 513b8c8e..ff80ed5b 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -244,6 +244,7 @@ import { KeyboardModifiersObject, CollaboratorPointer, ToolType, + OnUserFollowedPayload, } from "../types"; import { debounce, @@ -396,6 +397,7 @@ import { COLOR_PALETTE } from "../colors"; import { ElementCanvasButton } from "./MagicButton"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; import { EditorLocalStorage } from "../data/EditorLocalStorage"; +import FollowMode from "./FollowMode/FollowMode"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); @@ -551,6 +553,10 @@ class App extends React.Component { event: PointerEvent, ] >(); + onUserFollowEmitter = new Emitter<[payload: OnUserFollowedPayload]>(); + onScrollChangeEmitter = new Emitter< + [scrollX: number, scrollY: number, zoom: AppState["zoom"]] + >(); constructor(props: AppProps) { super(props); @@ -620,6 +626,8 @@ class App extends React.Component { onChange: (cb) => this.onChangeEmitter.on(cb), onPointerDown: (cb) => this.onPointerDownEmitter.on(cb), onPointerUp: (cb) => this.onPointerUpEmitter.on(cb), + onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb), + onUserFollow: (cb) => this.onUserFollowEmitter.on(cb), } as const; if (typeof excalidrawAPI === "function") { excalidrawAPI(api); @@ -1582,6 +1590,14 @@ class App extends React.Component { onPointerDown={this.handleCanvasPointerDown} onDoubleClick={this.handleCanvasDoubleClick} /> + {this.state.userToFollow && ( + + )} {this.renderFrameNames()} {this.renderEmbeddables()} @@ -2531,11 +2547,45 @@ class App extends React.Component { this.refreshEditorBreakpoints(); } + const hasFollowedPersonLeft = + prevState.userToFollow && + !this.state.collaborators.has(prevState.userToFollow.socketId); + + if (hasFollowedPersonLeft) { + this.maybeUnfollowRemoteUser(); + } + if ( + prevState.zoom.value !== this.state.zoom.value || prevState.scrollX !== this.state.scrollX || prevState.scrollY !== this.state.scrollY ) { - this.props?.onScrollChange?.(this.state.scrollX, this.state.scrollY); + this.props?.onScrollChange?.( + this.state.scrollX, + this.state.scrollY, + this.state.zoom, + ); + this.onScrollChangeEmitter.trigger( + this.state.scrollX, + this.state.scrollY, + this.state.zoom, + ); + } + + if (prevState.userToFollow !== this.state.userToFollow) { + if (prevState.userToFollow) { + this.onUserFollowEmitter.trigger({ + userToFollow: prevState.userToFollow, + action: "UNFOLLOW", + }); + } + + if (this.state.userToFollow) { + this.onUserFollowEmitter.trigger({ + userToFollow: this.state.userToFollow, + action: "FOLLOW", + }); + } } if ( @@ -3421,11 +3471,18 @@ class App extends React.Component { } }; + private maybeUnfollowRemoteUser = () => { + if (this.state.userToFollow) { + this.setState({ userToFollow: null }); + } + }; + /** use when changing scrollX/scrollY/zoom based on user interaction */ private translateCanvas: React.Component["setState"] = ( state, ) => { this.cancelInProgresAnimation?.(); + this.maybeUnfollowRemoteUser(); this.setState(state); }; @@ -5154,6 +5211,8 @@ class App extends React.Component { private handleCanvasPointerDown = ( event: React.PointerEvent, ) => { + this.maybeUnfollowRemoteUser(); + // since contextMenu options are potentially evaluated on each render, // and an contextMenu action may depend on selection state, we must // close the contextMenu before we update the selection on pointerDown diff --git a/packages/excalidraw/components/Avatar.scss b/packages/excalidraw/components/Avatar.scss index c0c66f0a..29eece22 100644 --- a/packages/excalidraw/components/Avatar.scss +++ b/packages/excalidraw/components/Avatar.scss @@ -2,34 +2,6 @@ .excalidraw { .Avatar { - width: 1.25rem; - height: 1.25rem; - position: relative; - border-radius: 100%; - outline-offset: 2px; - display: flex; - justify-content: center; - align-items: center; - cursor: pointer; - font-size: 0.75rem; - font-weight: 800; - line-height: 1; - - &-img { - width: 100%; - height: 100%; - border-radius: 100%; - } - - &::before { - content: ""; - position: absolute; - top: -3px; - right: -3px; - bottom: -3px; - left: -3px; - border: 1px solid var(--avatar-border-color); - border-radius: 100%; - } + @include avatarStyles; } } diff --git a/packages/excalidraw/components/Avatar.tsx b/packages/excalidraw/components/Avatar.tsx index 8b4624b7..82ec88c3 100644 --- a/packages/excalidraw/components/Avatar.tsx +++ b/packages/excalidraw/components/Avatar.tsx @@ -2,21 +2,33 @@ import "./Avatar.scss"; import React, { useState } from "react"; import { getNameInitial } from "../clients"; +import clsx from "clsx"; type AvatarProps = { onClick: (e: React.MouseEvent) => void; color: string; name: string; src?: string; + isBeingFollowed?: boolean; }; -export const Avatar = ({ color, onClick, name, src }: AvatarProps) => { +export const Avatar = ({ + color, + onClick, + name, + src, + isBeingFollowed, +}: AvatarProps) => { const shortName = getNameInitial(name); const [error, setError] = useState(false); const loadImg = !error && src; const style = loadImg ? undefined : { background: color }; return ( -
+
{loadImg ? ( void; +} + +const FollowMode = ({ + height, + width, + userToFollow, + onDisconnect, +}: FollowModeProps) => { + return ( +
+
+
+
+ Following{" "} + + {userToFollow.username} + +
+ +
+
+
+ ); +}; + +export default FollowMode; diff --git a/packages/excalidraw/components/Sidebar/SidebarTrigger.scss b/packages/excalidraw/components/Sidebar/SidebarTrigger.scss index 834df656..fd8bf814 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTrigger.scss +++ b/packages/excalidraw/components/Sidebar/SidebarTrigger.scss @@ -21,6 +21,10 @@ width: var(--lg-icon-size); height: var(--lg-icon-size); } + + &__label-element { + align-self: flex-start; + } } .default-sidebar-trigger .sidebar-trigger__label { diff --git a/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx b/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx index 71143281..889156eb 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx +++ b/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx @@ -19,7 +19,7 @@ export const SidebarTrigger = ({ const appState = useUIAppState(); return ( -