Skip to content

Commit

Permalink
[pr] follow-mode
Browse files Browse the repository at this point in the history
  • Loading branch information
dwelle committed Dec 13, 2023
1 parent 7158287 commit f2a43de
Show file tree
Hide file tree
Showing 27 changed files with 1,018 additions and 125 deletions.
6 changes: 6 additions & 0 deletions excalidraw-app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,12 @@ const ExcalidrawWrapper = () => {
})}
>
<Excalidraw
onScrollAndZoomChange={() => {
collabAPI?.relaySceneBounds();
}}
onUserFollowed={(userToFollow) => {
collabAPI?.onUserFollowed(userToFollow);
}}
excalidrawAPI={excalidrawRefCallback}
onChange={onChange}
initialData={initialStatePromiseRef.current.promise}
Expand Down
82 changes: 81 additions & 1 deletion excalidraw-app/collab/Collab.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -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"];
Expand Down Expand Up @@ -165,6 +173,8 @@ class Collab extends PureComponent<Props, CollabState> {
const collabAPI: CollabAPI = {
isCollaborating: this.isCollaborating,
onPointerUpdate: this.onPointerUpdate,
relaySceneBounds: this.relaySceneBounds,
onUserFollowed: this.onUserFollowed,
startCollaboration: this.startCollaboration,
syncElements: this.syncElements,
fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
Expand Down Expand Up @@ -513,6 +523,7 @@ class Collab extends PureComponent<Props, CollabState> {
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)
Expand All @@ -530,6 +541,29 @@ class Collab extends PureComponent<Props, CollabState> {
});
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);
Expand All @@ -556,6 +590,14 @@ class Collab extends PureComponent<Props, CollabState> {
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({
Expand Down Expand Up @@ -763,6 +805,44 @@ class Collab extends PureComponent<Props, CollabState> {
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);
};
Expand Down
39 changes: 37 additions & 2 deletions excalidraw-app/collab/Portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -83,6 +86,7 @@ class Portal {
async _broadcastSocketData(
data: SocketUpdateData,
volatile: boolean = false,
roomId?: string,
) {
if (this.isOpen()) {
const json = JSON.stringify(data);
Expand All @@ -91,7 +95,7 @@ class Portal {

this.socket?.emit(
volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER,
this.roomId,
roomId ?? this.roomId,
encryptedBuffer,
iv,
);
Expand Down Expand Up @@ -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;
8 changes: 8 additions & 0 deletions excalidraw-app/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
57 changes: 47 additions & 10 deletions packages/excalidraw/actions/actionCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export const actionZoomIn = register({
},
appState,
),
userToFollow: null,
},
commitToHistory: false,
};
Expand Down Expand Up @@ -146,6 +147,7 @@ export const actionZoomOut = register({
},
appState,
),
userToFollow: null,
},
commitToHistory: false,
};
Expand Down Expand Up @@ -183,6 +185,7 @@ export const actionResetZoom = register({
},
appState,
),
userToFollow: null,
},
commitToHistory: false,
};
Expand Down Expand Up @@ -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<AppState>;
/** 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;

Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -311,6 +312,29 @@ export const zoomToFit = ({
};
};

export const zoomToFit = ({
targetElements,
appState,
fitToViewport,
viewportZoomFactor,
}: {
targetElements: readonly ExcalidrawElement[];
appState: Readonly<AppState>;
/** 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.
Expand All @@ -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,
});
},
Expand All @@ -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,
});
},
Expand All @@ -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 &&
Expand Down

0 comments on commit f2a43de

Please sign in to comment.