From aad8ab012330dddf09fe8349dc744143fa5e40da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barnab=C3=A1s=20Moln=C3=A1r?= <38168628+barnabasmolnar@users.noreply.github.com> Date: Fri, 15 Dec 2023 00:07:11 +0100 Subject: [PATCH] feat: follow mode (#6848) Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- excalidraw-app/app_constants.ts | 7 +- excalidraw-app/collab/Collab.tsx | 148 ++++++++-- excalidraw-app/collab/Portal.tsx | 49 +++- 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 | 61 ++++- packages/excalidraw/components/Avatar.scss | 30 +- packages/excalidraw/components/Avatar.tsx | 16 +- .../components/FollowMode/FollowMode.scss | 59 ++++ .../components/FollowMode/FollowMode.tsx | 43 +++ .../components/Sidebar/SidebarTrigger.scss | 4 + .../components/Sidebar/SidebarTrigger.tsx | 2 +- packages/excalidraw/components/UserList.scss | 100 ++++++- packages/excalidraw/components/UserList.tsx | 259 +++++++++++++++--- packages/excalidraw/components/icons.tsx | 9 + packages/excalidraw/css/theme.scss | 2 + packages/excalidraw/css/variables.module.scss | 38 +++ packages/excalidraw/index.tsx | 1 + packages/excalidraw/locales/en.json | 9 + .../__snapshots__/contextmenu.test.tsx.snap | 34 +++ .../regressionTests.test.tsx.snap | 104 +++++++ .../packages/__snapshots__/utils.test.ts.snap | 3 + packages/excalidraw/types.ts | 31 ++- packages/excalidraw/utils.ts | 37 ++- .../utils/__snapshots__/export.test.ts.snap | 2 + .../utils/__snapshots__/utils.test.ts.snap | 2 + 28 files changed, 1038 insertions(+), 137 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_constants.ts b/excalidraw-app/app_constants.ts index 179fe52e73..a20a23506a 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 6ecdd15753..99fc4361ee 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.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({ - collaborators, + 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 4e50543294..7486486cee 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 6bab983321..b162da9a40 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 f61f57dbd3..7d57c64a75 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 126e547ae2..11dc221285 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 0089f57e98..4dec9a7901 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 513b8c8e27..ff80ed5b0b 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 c0c66f0a2c..29eece2209 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 8b4624b7f4..82ec88c377 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 834df65635..fd8bf814af 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 7114328187..889156eba6 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 ( -
+ ) : ( +
+ {firstNAvatarsJSX} + + {uniqueCollaboratorsArray.length > FIRST_N_AVATARS && ( + { + if (!isOpen) { + setSearchTerm(""); + } + }} > - {avatarJSX} - - ) : ( - {avatarJSX} - ); - }); + + +{uniqueCollaboratorsArray.length - FIRST_N_AVATARS} + + + + {uniqueCollaboratorsArray.length >= + SHOW_COLLABORATORS_FILTER_AT && ( +
+ {searchIcon} + { + setSearchTerm(e.target.value); + }} + /> +
+ )} +
+ {filteredCollaborators.length === 0 && ( +
+ {t("userList.search.empty")} +
+ )} +
+ {t("userList.hint.text")} +
+ {filteredCollaborators.map(([clientId, collaborator]) => + renderCollaborator({ + actionManager, + collaborator, + clientId, + withName: true, + }), + )} +
+
+
+
+ )} +
+ ); + }, + (prev, next) => { + if ( + prev.collaborators.size !== next.collaborators.size || + prev.mobile !== next.mobile || + prev.className !== next.className + ) { + return false; + } - return ( -
- {avatars} -
- ); -}; + for (const [socketId, collaborator] of prev.collaborators) { + const nextCollaborator = next.collaborators.get(socketId); + if ( + !nextCollaborator || + !isShallowEqual( + collaborator, + nextCollaborator, + collaboratorComparatorKeys, + ) + ) { + return false; + } + } + return true; + }, +); diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index 58664e1684..62fc395742 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -1823,3 +1823,12 @@ export const brainIcon = createIcon( , tablerIconProps, ); + +export const searchIcon = createIcon( + + + + + , + tablerIconProps, +); diff --git a/packages/excalidraw/css/theme.scss b/packages/excalidraw/css/theme.scss index 4fb8bf81f7..4bcf717549 100644 --- a/packages/excalidraw/css/theme.scss +++ b/packages/excalidraw/css/theme.scss @@ -84,6 +84,7 @@ --color-primary-darkest: #4a47b1; --color-primary-light: #e3e2fe; --color-primary-light-darker: #d7d5ff; + --color-primary-hover: #5753d0; --color-gray-10: #f5f5f5; --color-gray-20: #ebebeb; @@ -205,6 +206,7 @@ --color-primary-darkest: #beb9ff; --color-primary-light: #4f4d6f; --color-primary-light-darker: #43415e; + --color-primary-hover: #bbb8ff; --color-text-warning: var(--color-gray-80); diff --git a/packages/excalidraw/css/variables.module.scss b/packages/excalidraw/css/variables.module.scss index 634752dfa0..3499ac4af8 100644 --- a/packages/excalidraw/css/variables.module.scss +++ b/packages/excalidraw/css/variables.module.scss @@ -114,6 +114,44 @@ } } +@mixin avatarStyles { + 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; + color: var(--color-gray-90); + flex: 0 0 auto; + + &-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%; + } + + &--is-followed::before { + border-color: var(--color-primary-hover); + } +} + @mixin filledButtonOnCanvas { border: none; box-shadow: 0 0 0 1px var(--color-surface-lowest); diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index e8c0834157..74aa6d8d5e 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -247,6 +247,7 @@ export { TTDDialog } from "./components/TTDDialog/TTDDialog"; export { TTDDialogTrigger } from "./components/TTDDialog/TTDDialogTrigger"; export { normalizeLink } from "./data/url"; +export { zoomToFitBounds } from "./actions/actionCanvas"; export { convertToExcalidrawElements } from "./data/transform"; export { getCommonBounds } from "./element/bounds"; diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 2f845bb10e..dc5e86f4a0 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -521,5 +521,14 @@ "description": "Currently only Flowchart, Sequence, and Class Diagrams are supported. The other types will be rendered as image in Excalidraw.", "syntax": "Mermaid Syntax", "preview": "Preview" + }, + "userList": { + "search": { + "placeholder": "Quick search", + "empty": "No users found" + }, + "hint": { + "text": "Click on user to follow" + } } } diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index d8ef5572ae..d672a25429 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -312,6 +312,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -370,6 +371,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 200, @@ -511,6 +513,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -565,6 +568,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "toast": { "message": "Added to library", }, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 200, @@ -713,6 +717,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -765,6 +770,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 200, @@ -1089,6 +1095,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -1141,6 +1148,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 200, @@ -1465,6 +1473,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -1519,6 +1528,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "toast": { "message": "Copied styles.", }, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 200, @@ -1667,6 +1677,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -1717,6 +1728,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 200, @@ -1906,6 +1918,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -1958,6 +1971,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 200, @@ -2210,6 +2224,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -2267,6 +2282,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 200, @@ -2602,6 +2618,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -2656,6 +2673,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "toast": { "message": "Copied styles.", }, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 200, @@ -3412,6 +3430,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -3464,6 +3483,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 200, @@ -3788,6 +3808,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -3840,6 +3861,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 200, @@ -4164,6 +4186,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -4219,6 +4242,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 200, @@ -4896,6 +4920,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -4951,6 +4976,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 200, @@ -5476,6 +5502,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -5533,6 +5560,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 200, @@ -5995,6 +6023,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -6048,6 +6077,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 200, @@ -6394,6 +6424,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -6446,6 +6477,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 200, @@ -6768,6 +6800,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -6823,6 +6856,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 200, diff --git a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap index 9cf919a9ce..452f9242c2 100644 --- a/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap @@ -39,6 +39,7 @@ exports[`given element A and group of elements B and given both are selected whe "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -98,6 +99,7 @@ exports[`given element A and group of elements B and given both are selected whe "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -495,6 +497,7 @@ exports[`given element A and group of elements B and given both are selected whe "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -556,6 +559,7 @@ exports[`given element A and group of elements B and given both are selected whe "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -953,6 +957,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -1005,6 +1010,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -1784,6 +1790,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -1838,6 +1845,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -1997,6 +2005,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -2054,6 +2063,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -2451,6 +2461,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -2505,6 +2516,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -2693,6 +2705,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = ` "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -2745,6 +2758,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = ` "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -2861,6 +2875,7 @@ exports[`regression tests > can drag element that covers another element, while "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -2915,6 +2930,7 @@ exports[`regression tests > can drag element that covers another element, while "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -3305,6 +3321,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -3357,6 +3374,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -3602,6 +3620,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -3656,6 +3675,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -3847,6 +3867,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -3901,6 +3922,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -4103,6 +4125,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`] "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -4157,6 +4180,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`] "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -4345,6 +4369,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -4400,6 +4425,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -4689,6 +4715,7 @@ exports[`regression tests > deleting last but one element in editing group shoul "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -4743,6 +4770,7 @@ exports[`regression tests > deleting last but one element in editing group shoul "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -5192,6 +5220,7 @@ exports[`regression tests > deselects group of selected elements on pointer down "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -5273,6 +5302,7 @@ exports[`regression tests > deselects group of selected elements on pointer down "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -5489,6 +5519,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -5542,6 +5573,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -5758,6 +5790,7 @@ exports[`regression tests > deselects selected element on pointer down when poin "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -5838,6 +5871,7 @@ exports[`regression tests > deselects selected element on pointer down when poin "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -5954,6 +5988,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -6006,6 +6041,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -6122,6 +6158,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -6174,6 +6211,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -6574,6 +6612,7 @@ exports[`regression tests > drags selected elements from point inside common bou "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -6630,6 +6669,7 @@ exports[`regression tests > drags selected elements from point inside common bou "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -6891,6 +6931,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -6941,6 +6982,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1` "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -8958,6 +9000,7 @@ exports[`regression tests > given a group of selected elements with an element t "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -9013,6 +9056,7 @@ exports[`regression tests > given a group of selected elements with an element t "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -9302,6 +9346,7 @@ exports[`regression tests > given a selected element A and a not selected elemen "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -9357,6 +9402,7 @@ exports[`regression tests > given a selected element A and a not selected elemen "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -9545,6 +9591,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -9599,6 +9646,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -9744,6 +9792,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -9798,6 +9847,7 @@ exports[`regression tests > given selected element A with lower z-index than uns "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -10015,6 +10065,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -10067,6 +10118,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -10183,6 +10235,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -10235,6 +10288,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -10351,6 +10405,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -10403,6 +10458,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -10519,6 +10575,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -10594,6 +10651,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1` "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -10725,6 +10783,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -10800,6 +10859,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`] "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -10931,6 +10991,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -10981,6 +11042,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -11117,6 +11179,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -11192,6 +11255,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1` "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -11323,6 +11387,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -11375,6 +11440,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -11491,6 +11557,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -11566,6 +11633,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`] "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -11697,6 +11765,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -11749,6 +11818,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -11865,6 +11935,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -11915,6 +11986,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -12051,6 +12123,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -12103,6 +12176,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -12219,6 +12293,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -12279,6 +12354,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -12883,6 +12959,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -12937,6 +13014,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -13125,6 +13203,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = ` "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -13175,6 +13254,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = ` "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -13248,6 +13328,7 @@ exports[`regression tests > shift click on selected element should deselect it o "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -13300,6 +13381,7 @@ exports[`regression tests > shift click on selected element should deselect it o "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -13416,6 +13498,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -13472,6 +13555,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -13733,6 +13817,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -13791,6 +13876,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -14294,6 +14380,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -14356,6 +14443,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -15150,6 +15238,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -15203,6 +15292,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -15276,6 +15366,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`] "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -15330,6 +15421,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`] "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -16091,6 +16183,7 @@ exports[`regression tests > switches from group of selected elements to another "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -16174,6 +16267,7 @@ exports[`regression tests > switches from group of selected elements to another "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -16491,6 +16585,7 @@ exports[`regression tests > switches selected element on pointer down > [end of "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -16573,6 +16668,7 @@ exports[`regression tests > switches selected element on pointer down > [end of "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -16761,6 +16857,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`] "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -16811,6 +16908,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`] "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -16884,6 +16982,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -16936,6 +17035,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -17367,6 +17467,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -17417,6 +17518,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, @@ -17490,6 +17592,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = ` "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -17543,6 +17646,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = ` "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "width": 1024, diff --git a/packages/excalidraw/tests/packages/__snapshots__/utils.test.ts.snap b/packages/excalidraw/tests/packages/__snapshots__/utils.test.ts.snap index 254d4163b6..610d97eb32 100644 --- a/packages/excalidraw/tests/packages/__snapshots__/utils.test.ts.snap +++ b/packages/excalidraw/tests/packages/__snapshots__/utils.test.ts.snap @@ -9,6 +9,7 @@ exports[`exportToSvg > with default arguments 1`] = ` "locked": false, "type": "selection", }, + "amIBeingFollowed": false, "collaborators": Map {}, "contextMenu": null, "currentChartType": "bar", @@ -82,6 +83,7 @@ exports[`exportToSvg > with default arguments 1`] = ` "selectedLinearElement": null, "selectionElement": null, "shouldCacheIgnoreZoom": false, + "shouldDisconnectFollowModeOnCanvasInteraction": true, "showHyperlinkPopup": false, "showStats": false, "showWelcomeScreen": false, @@ -90,6 +92,7 @@ exports[`exportToSvg > with default arguments 1`] = ` "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "zenModeEnabled": false, diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index ee1287b13a..7fe7f1363f 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -41,7 +41,9 @@ import { Merge, ValueOf } from "./utility-types"; export type Point = Readonly; -export type Collaborator = { +export type SocketId = string; + +export type Collaborator = Readonly<{ pointer?: CollaboratorPointer; button?: "up" | "down"; selectedElementIds?: AppState["selectedElementIds"]; @@ -56,7 +58,8 @@ export type Collaborator = { avatarUrl?: string; // user id. If supplied, we'll filter out duplicates when rendering user avatars. id?: string; -}; + socketId?: SocketId; +}>; export type CollaboratorPointer = { x: number; @@ -123,6 +126,11 @@ export type ActiveTool = export type SidebarName = string; export type SidebarTabName = string; +export type UserToFollow = { + socketId: string; + username: string; +}; + type _CommonCanvasAppState = { zoom: AppState["zoom"]; scrollX: AppState["scrollX"]; @@ -303,13 +311,16 @@ export interface AppState { pendingImageElementId: ExcalidrawImageElement["id"] | null; showHyperlinkPopup: false | "info" | "editor"; selectedLinearElement: LinearElementEditor | null; - snapLines: readonly SnapLine[]; originSnapOffset: { x: number; y: number; } | null; objectsSnapModeEnabled: boolean; + /** the user's clientId & username who is being followed on the canvas */ + userToFollow: UserToFollow | null; + /** the clientIds of the users following the current user */ + followedBy: Set; } export type UIAppState = Omit< @@ -385,6 +396,11 @@ export type ExcalidrawInitialDataState = Merge< } >; +export type OnUserFollowedPayload = { + userToFollow: UserToFollow; + action: "FOLLOW" | "UNFOLLOW"; +}; + export interface ExcalidrawProps { onChange?: ( elements: readonly ExcalidrawElement[], @@ -438,7 +454,8 @@ export interface ExcalidrawProps { activeTool: AppState["activeTool"], pointerDownState: PointerDownState, ) => void; - onScrollChange?: (scrollX: number, scrollY: number) => void; + onScrollChange?: (scrollX: number, scrollY: number, zoom: Zoom) => void; + onUserFollow?: (payload: OnUserFollowedPayload) => void; children?: React.ReactNode; validateEmbeddable?: | boolean @@ -675,6 +692,12 @@ export type ExcalidrawImperativeAPI = { event: PointerEvent, ) => void, ) => UnsubscribeCallback; + onScrollChange: ( + callback: (scrollX: number, scrollY: number, zoom: Zoom) => void, + ) => UnsubscribeCallback; + onUserFollow: ( + callback: (payload: OnUserFollowedPayload) => void, + ) => UnsubscribeCallback; }; export type Device = Readonly<{ diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index c092fea7a6..69b3426e44 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -771,11 +771,21 @@ export const queryFocusableElements = (container: HTMLElement | null) => { export const isShallowEqual = < T extends Record, - I extends keyof T, + K extends readonly unknown[], >( objA: T, objB: T, - comparators?: Record boolean>, + comparators?: + | { [key in keyof T]?: (a: T[key], b: T[key]) => boolean } + | (keyof T extends K[number] + ? K extends readonly (keyof T)[] + ? K + : { + _error: "keys are either missing or include keys not in compared obj"; + } + : { + _error: "keys are either missing or include keys not in compared obj"; + }), debug = false, ) => { const aKeys = Object.keys(objA); @@ -783,8 +793,29 @@ export const isShallowEqual = < if (aKeys.length !== bKeys.length) { return false; } + + if (comparators && Array.isArray(comparators)) { + for (const key of comparators) { + const ret = objA[key] === objB[key]; + if (!ret) { + if (debug) { + console.info( + `%cisShallowEqual: ${key} not equal ->`, + "color: #8B4000", + objA[key], + objB[key], + ); + } + return false; + } + } + return true; + } + return aKeys.every((key) => { - const comparator = comparators?.[key as I]; + const comparator = ( + comparators as { [key in keyof T]?: (a: T[key], b: T[key]) => boolean } + )?.[key as keyof T]; const ret = comparator ? comparator(objA[key], objB[key]) : objA[key] === objB[key]; diff --git a/packages/utils/__snapshots__/export.test.ts.snap b/packages/utils/__snapshots__/export.test.ts.snap index 254d4163b6..fdcb71295c 100644 --- a/packages/utils/__snapshots__/export.test.ts.snap +++ b/packages/utils/__snapshots__/export.test.ts.snap @@ -40,6 +40,7 @@ exports[`exportToSvg > with default arguments 1`] = ` "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -90,6 +91,7 @@ exports[`exportToSvg > with default arguments 1`] = ` "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "zenModeEnabled": false, diff --git a/packages/utils/__snapshots__/utils.test.ts.snap b/packages/utils/__snapshots__/utils.test.ts.snap index 254d4163b6..fdcb71295c 100644 --- a/packages/utils/__snapshots__/utils.test.ts.snap +++ b/packages/utils/__snapshots__/utils.test.ts.snap @@ -40,6 +40,7 @@ exports[`exportToSvg > with default arguments 1`] = ` "exportScale": 1, "exportWithDarkMode": false, "fileHandle": null, + "followedBy": Set {}, "frameRendering": { "clip": true, "enabled": true, @@ -90,6 +91,7 @@ exports[`exportToSvg > with default arguments 1`] = ` "suggestedBindings": [], "theme": "light", "toast": null, + "userToFollow": null, "viewBackgroundColor": "#ffffff", "viewModeEnabled": false, "zenModeEnabled": false,