From f2a43dea6d2c78c023f7054c434ece31584e47c6 Mon Sep 17 00:00:00 2001 From: dwelle <5153846+dwelle@users.noreply.github.com> Date: Wed, 13 Dec 2023 13:32:43 +0100 Subject: [PATCH] [pr] follow-mode --- excalidraw-app/App.tsx | 6 + excalidraw-app/collab/Collab.tsx | 82 +++++- excalidraw-app/collab/Portal.tsx | 39 ++- excalidraw-app/data/index.ts | 8 + packages/excalidraw/actions/actionCanvas.tsx | 57 +++- .../excalidraw/actions/actionNavigate.tsx | 56 +++- packages/excalidraw/appState.ts | 4 + packages/excalidraw/components/App.tsx | 51 +++- packages/excalidraw/components/Avatar.scss | 30 +- packages/excalidraw/components/Avatar.tsx | 16 +- .../components/FollowMode/FollowMode.scss | 59 ++++ .../components/FollowMode/FollowMode.tsx | 50 ++++ .../components/Sidebar/SidebarTrigger.scss | 4 + .../components/Sidebar/SidebarTrigger.tsx | 2 +- packages/excalidraw/components/UserList.scss | 111 ++++++- packages/excalidraw/components/UserList.tsx | 278 ++++++++++++++++-- .../components/canvases/InteractiveCanvas.tsx | 65 ++-- packages/excalidraw/components/icons.tsx | 9 + packages/excalidraw/css/theme.scss | 2 + packages/excalidraw/css/variables.module.scss | 36 +++ packages/excalidraw/index.tsx | 5 + packages/excalidraw/locales/en.json | 10 + .../__snapshots__/contextmenu.test.tsx.snap | 34 +++ .../regressionTests.test.tsx.snap | 104 +++++++ .../packages/__snapshots__/utils.test.ts.snap | 3 + packages/excalidraw/types.ts | 20 +- .../utils/__snapshots__/utils.test.ts.snap | 2 + 27 files changed, 1018 insertions(+), 125 deletions(-) create mode 100644 packages/excalidraw/components/FollowMode/FollowMode.scss create mode 100644 packages/excalidraw/components/FollowMode/FollowMode.tsx diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index f87703be5f4d..b4335524c85a 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -764,6 +764,12 @@ const ExcalidrawWrapper = () => { })} > { + collabAPI?.relaySceneBounds(); + }} + onUserFollowed={(userToFollow) => { + collabAPI?.onUserFollowed(userToFollow); + }} excalidrawAPI={excalidrawRefCallback} onChange={onChange} initialData={initialStatePromiseRef.current.promise} diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx index 6ecdd15753f5..81c9e6f4d1ca 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, + OnUserFollowedPayload, +} 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 { @@ -92,6 +98,8 @@ export interface CollabAPI { /** function so that we can access the latest value from stale callbacks */ isCollaborating: () => boolean; onPointerUpdate: CollabInstance["onPointerUpdate"]; + relaySceneBounds: CollabInstance["relaySceneBounds"]; + onUserFollowed: CollabInstance["onUserFollowed"]; startCollaboration: CollabInstance["startCollaboration"]; stopCollaboration: CollabInstance["stopCollaboration"]; syncElements: CollabInstance["syncElements"]; @@ -165,6 +173,8 @@ class Collab extends PureComponent { const collabAPI: CollabAPI = { isCollaborating: this.isCollaborating, onPointerUpdate: this.onPointerUpdate, + relaySceneBounds: this.relaySceneBounds, + onUserFollowed: this.onUserFollowed, startCollaboration: this.startCollaboration, syncElements: this.syncElements, fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase, @@ -513,6 +523,7 @@ 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) @@ -530,6 +541,29 @@ class Collab extends PureComponent { }); break; } + + case "SCENE_BOUNDS": { + const { bounds } = decryptedData.payload; + + const _appState = this.excalidrawAPI.getAppState(); + + const { userToFollow, followedBy } = _appState; + // cross-follow case, ignore updates in this case + if (userToFollow && followedBy.has(userToFollow.clientId)) { + return; + } + + const { appState } = zoomToFitBounds({ + appState: _appState, + bounds, + fitToViewport: true, + viewportZoomFactor: 1, + }); + this.excalidrawAPI.updateScene({ appState }); + + break; + } + case "IDLE_STATUS": { const { userState, socketId, username } = decryptedData.payload; const collaborators = new Map(this.collaborators); @@ -556,6 +590,14 @@ class Collab extends PureComponent { scenePromise.resolve(sceneData); }); + this.portal.socket.on("follow-room-user-change", (followedBy: string[]) => { + this.excalidrawAPI.updateScene({ + appState: { followedBy: new Set(followedBy) }, + }); + + this.relaySceneBounds({ shouldPerform: true }); + }); + this.initializeIdleDetector(); this.setState({ @@ -763,6 +805,44 @@ class Collab extends PureComponent { CURSOR_SYNC_TIMEOUT, ); + relaySceneBounds = throttleRAF((props?: { shouldPerform: boolean }) => { + const appState = this.excalidrawAPI.getAppState(); + + if (appState.followedBy.size > 0 || props?.shouldPerform) { + const { x: x1, y: y1 } = viewportCoordsToSceneCoords( + { clientX: 0, clientY: 0 }, + { + offsetLeft: appState.offsetLeft, + offsetTop: appState.offsetTop, + scrollX: appState.scrollX, + scrollY: appState.scrollY, + zoom: appState.zoom, + }, + ); + + const { x: x2, y: y2 } = viewportCoordsToSceneCoords( + { clientX: appState.width, clientY: appState.height }, + { + offsetLeft: appState.offsetLeft, + offsetTop: appState.offsetTop, + scrollX: appState.scrollX, + scrollY: appState.scrollY, + zoom: appState.zoom, + }, + ); + + this.portal.socket && + this.portal.broadcastSceneBounds( + { bounds: [x1, y1, x2, y2] }, + `follow_${this.portal.socket.id}`, + ); + } + }); + + onUserFollowed = (payload: OnUserFollowedPayload) => { + this.portal.socket && this.portal.broadcastUserFollowed(payload); + }; + onIdleStateChange = (userState: UserIdleState) => { this.portal.broadcastIdleChange(userState); }; diff --git a/excalidraw-app/collab/Portal.tsx b/excalidraw-app/collab/Portal.tsx index 4e505432941d..77073287b3f1 100644 --- a/excalidraw-app/collab/Portal.tsx +++ b/excalidraw-app/collab/Portal.tsx @@ -12,7 +12,10 @@ import { FILE_UPLOAD_TIMEOUT, WS_SCENE_EVENT_TYPES, } from "../app_constants"; -import { UserIdleState } from "../../packages/excalidraw/types"; +import { + OnUserFollowedPayload, + UserIdleState, +} from "../../packages/excalidraw/types"; import { trackEvent } from "../../packages/excalidraw/analytics"; import throttle from "lodash.throttle"; import { newElementWith } from "../../packages/excalidraw/element/mutateElement"; @@ -83,6 +86,7 @@ class Portal { async _broadcastSocketData( data: SocketUpdateData, volatile: boolean = false, + roomId?: string, ) { if (this.isOpen()) { const json = JSON.stringify(data); @@ -91,7 +95,7 @@ class Portal { this.socket?.emit( volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER, - this.roomId, + roomId ?? this.roomId, encryptedBuffer, iv, ); @@ -213,12 +217,43 @@ class Portal { username: this.collab.state.username, }, }; + return this._broadcastSocketData( data as SocketUpdateData, true, // volatile ); } }; + + broadcastSceneBounds = ( + payload: { + bounds: [number, number, number, number]; + }, + roomId: string, + ) => { + if (this.socket?.id) { + const data: SocketUpdateDataSource["SCENE_BOUNDS"] = { + type: "SCENE_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("on-user-follow", payload); + } + }; } export default Portal; diff --git a/excalidraw-app/data/index.ts b/excalidraw-app/data/index.ts index 6bab983321d3..05e8f89f181b 100644 --- a/excalidraw-app/data/index.ts +++ b/excalidraw-app/data/index.ts @@ -119,6 +119,14 @@ export type SocketUpdateDataSource = { username: string; }; }; + SCENE_BOUNDS: { + type: "SCENE_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 f61f57dbd347..7d57c64a7503 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 126e547ae2b6..c4099a0a7780 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 & { clientId: string }; + const point = _value.pointer; + if (!point) { return { appState, commitToHistory: false }; } + if (appState.userToFollow?.clientId === _value.clientId) { + return { + appState: { + ...appState, + userToFollow: null, + }, + commitToHistory: false, + }; + } + return { appState: { ...appState, - ...centerScrollOn({ - scenePoint: point, - viewportDimensions: { - width: appState.width, - height: appState.height, - }, - zoom: appState.zoom, - }), + userToFollow: { + clientId: _value.clientId, + 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?.clientId === clientId} + /> + {collaborator.username} +
+ ) : ( updateData(collaborator.pointer)} + onClick={() => { + updateData({ ...collaborator, clientId }); + }} name={collaborator.username || ""} src={collaborator.avatarUrl} + isBeingFollowed={appState.userToFollow?.clientId === clientId} /> ); }, diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 6b34850ac874..53f932660fd6 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -106,6 +106,8 @@ export const getDefaultAppState = (): Omit< y: 0, }, objectsSnapModeEnabled: false, + userToFollow: null, + followedBy: new Set(), }; }; @@ -216,6 +218,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 dffa929c4515..6ae0bf2dba68 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -1591,6 +1591,10 @@ class App extends React.Component { onTouchMove={this.handleTouchMove} onPointerDown={this.handleCanvasPointerDown} onDoubleClick={this.handleCanvasDoubleClick} + userToFollow={this.state.userToFollow} + resetUserToFollow={() => { + this.setState({ userToFollow: null }); + }} /> {this.renderFrameNames()} @@ -2589,13 +2593,45 @@ class App extends React.Component { this.setActiveTool(this.props.activeTool); } - if ( + const hasFollowedPersonLeft = + prevState.userToFollow && + !this.state.collaborators.has(prevState.userToFollow.clientId); + + if (hasFollowedPersonLeft) { + this.setState({ userToFollow: null }); + } + + const hasScrollChanged = prevState.scrollX !== this.state.scrollX || - prevState.scrollY !== this.state.scrollY - ) { + prevState.scrollY !== this.state.scrollY; + + if (hasScrollChanged) { this.props?.onScrollChange?.(this.state.scrollX, this.state.scrollY); } + if (prevState.zoom.value !== this.state.zoom.value || hasScrollChanged) { + this.props?.onScrollAndZoomChange?.({ + zoom: this.state.zoom, + scroll: { x: this.state.scrollX, y: this.state.scrollY }, + }); + } + + if (prevState.userToFollow !== this.state.userToFollow) { + if (prevState.userToFollow) { + this.props?.onUserFollowed?.({ + userToFollow: prevState.userToFollow, + action: "unfollow", + }); + } + + if (this.state.userToFollow) { + this.props?.onUserFollowed?.({ + userToFollow: this.state.userToFollow, + action: "follow", + }); + } + } + if ( Object.keys(this.state.selectedElementIds).length && isEraserActive(this.state) @@ -3511,7 +3547,12 @@ class App extends React.Component { state, ) => { this.cancelInProgresAnimation?.(); - this.setState(state); + + // unfollow participant + this.setState((prevState, props) => ({ + ...(typeof state === "function" ? state(prevState, props) : state), + userToFollow: null, + })); }; setToast = ( @@ -5263,6 +5304,8 @@ class App extends React.Component { private handleCanvasPointerDown = ( event: React.PointerEvent, ) => { + this.setState({ userToFollow: null }); + // 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 c0c66f0a2cdd..29eece2209c9 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 8b4624b7f4e7..82ec88c3773d 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 = ({ + children, + height, + width, + userToFollow, + onDisconnect, +}: FollowModeProps) => { + if (!userToFollow) { + return <>{children}; + } + + return ( +
+
+
+
+ Following{" "} + + {userToFollow.username} + +
+ +
+
+ {children} +
+ ); +}; + +export default FollowMode; diff --git a/packages/excalidraw/components/Sidebar/SidebarTrigger.scss b/packages/excalidraw/components/Sidebar/SidebarTrigger.scss index 834df656354b..fd8bf814af68 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 711432818770..889156eba6f9 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 ( -