feat: follow mode (#6848)

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Barnabás Molnár 2023-12-15 00:07:11 +01:00 committed by GitHub
parent 88a2b286c7
commit aad8ab0123
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1039 additions and 138 deletions

View File

@ -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 = {

View File

@ -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<CollabAPI | null>(null);
export const collabDialogShownAtom = atom(false);
@ -154,12 +162,28 @@ class Collab extends PureComponent<Props, CollabState> {
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<Props, CollabState> {
window.clearTimeout(this.idleTimeoutId);
this.idleTimeoutId = null;
}
this.onUmmount?.();
}
isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!;
@ -489,7 +514,7 @@ class Collab extends PureComponent<Props, CollabState> {
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<Props, CollabState> {
}
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<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)
decryptedData.payload.socketID;
const collaborators = new Map(this.collaborators);
const user = collaborators.get(socketId) || {}!;
user.pointer = pointer;
user.button = button;
user.selectedElementIds = selectedElementIds;
user.username = username;
collaborators.set(socketId, user);
this.excalidrawAPI.updateScene({
collaborators,
this.updateCollaborator(socketId, {
pointer,
button,
selectedElementIds,
username,
});
break;
}
case WS_SUBTYPES.USER_VIEWPORT_BOUNDS: {
const { bounds, socketId } = decryptedData.payload;
const appState = this.excalidrawAPI.getAppState();
// we're not following the user
// (shouldn't happen, but could be late message or bug upstream)
if (appState.userToFollow?.socketId !== socketId) {
console.warn(
`receiving remote client's (from ${socketId}) viewport bounds even though we're not subscribed to it!`,
);
return;
}
// cross-follow case, ignore updates in this case
if (
appState.userToFollow &&
appState.followedBy.has(appState.userToFollow.socketId)
) {
return;
}
this.excalidrawAPI.updateScene({
appState: zoomToFitBounds({
appState,
bounds,
fitToViewport: true,
viewportZoomFactor: 1,
}).appState,
});
break;
}
case "IDLE_STATUS": {
const { userState, socketId, username } = decryptedData.payload;
const collaborators = new Map(this.collaborators);
const user = collaborators.get(socketId) || {}!;
user.userState = userState;
user.username = username;
this.excalidrawAPI.updateScene({
collaborators,
this.updateCollaborator(socketId, {
userState,
username,
});
break;
}
@ -556,6 +611,17 @@ class Collab extends PureComponent<Props, CollabState> {
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<Props, CollabState> {
this.excalidrawAPI.updateScene({ collaborators });
}
private updateCollaborator = (
socketId: SocketId,
updates: Partial<Collaborator>,
) => {
const collaborators = new Map(this.collaborators);
const user: Mutable<Collaborator> = 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<Props, CollabState> {
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<Props, CollabState> {
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<Props, CollabState> {
queueBroadcastAllElements = throttle(() => {
this.portal.broadcastScene(
WS_SCENE_EVENT_TYPES.UPDATE,
WS_SUBTYPES.UPDATE,
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
true,
);

View File

@ -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;

View File

@ -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: {

View File

@ -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<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;
@ -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<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.
@ -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 &&

View File

@ -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 ? (
<div
className="dropdown-menu-item dropdown-menu-item-base"
onClick={() => updateData({ ...collaborator, clientId })}
>
<Avatar
color={background}
onClick={() => {}}
name={collaborator.username || ""}
src={collaborator.avatarUrl}
isBeingFollowed={appState.userToFollow?.socketId === clientId}
/>
{collaborator.username}
</div>
) : (
<Avatar
color={background}
onClick={() => updateData(collaborator.pointer)}
onClick={() => {
updateData({ ...collaborator, clientId });
}}
name={collaborator.username || ""}
src={collaborator.avatarUrl}
isBeingFollowed={appState.userToFollow?.socketId === clientId}
/>
);
},

View File

@ -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 = <

View File

@ -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<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@ -551,6 +553,10 @@ class App extends React.Component<AppProps, AppState> {
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<AppProps, AppState> {
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<AppProps, AppState> {
onPointerDown={this.handleCanvasPointerDown}
onDoubleClick={this.handleCanvasDoubleClick}
/>
{this.state.userToFollow && (
<FollowMode
width={this.state.width}
height={this.state.height}
userToFollow={this.state.userToFollow}
onDisconnect={this.maybeUnfollowRemoteUser}
/>
)}
{this.renderFrameNames()}
</ExcalidrawActionManagerContext.Provider>
{this.renderEmbeddables()}
@ -2531,11 +2547,45 @@ class App extends React.Component<AppProps, AppState> {
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<AppProps, AppState> {
}
};
private maybeUnfollowRemoteUser = () => {
if (this.state.userToFollow) {
this.setState({ userToFollow: null });
}
};
/** use when changing scrollX/scrollY/zoom based on user interaction */
private translateCanvas: React.Component<any, AppState>["setState"] = (
state,
) => {
this.cancelInProgresAnimation?.();
this.maybeUnfollowRemoteUser();
this.setState(state);
};
@ -5154,6 +5211,8 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasPointerDown = (
event: React.PointerEvent<HTMLElement>,
) => {
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

View File

@ -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;
}
}

View File

@ -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<HTMLDivElement, 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 (
<div className="Avatar" style={style} onClick={onClick}>
<div
className={clsx("Avatar", { "Avatar--is-followed": isBeingFollowed })}
style={style}
onClick={onClick}
>
{loadImg ? (
<img
className="Avatar-img"

View File

@ -0,0 +1,59 @@
.excalidraw {
.follow-mode {
position: absolute;
box-sizing: border-box;
pointer-events: none;
border: 2px solid var(--color-primary-hover);
z-index: 9999;
display: flex;
align-items: flex-end;
justify-content: center;
&__badge {
background-color: var(--color-primary-hover);
color: var(--color-primary-light);
padding: 0.25rem 0.5rem;
margin-bottom: 0.5rem;
border-radius: 0.5rem;
pointer-events: all;
font-size: 0.75rem;
display: flex;
gap: 0.5rem;
align-items: center;
&__label {
display: flex;
white-space: pre-wrap;
line-height: 1;
}
&__username {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100px;
}
}
&__disconnect-btn {
all: unset;
cursor: pointer;
border-radius: 0.25rem;
&:hover {
background-color: var(--color-primary-darker);
}
&:active {
background-color: var(--color-primary-darkest);
}
svg {
display: block;
width: 1rem;
height: 1rem;
}
}
}
}

View File

@ -0,0 +1,43 @@
import { UserToFollow } from "../../types";
import { CloseIcon } from "../icons";
import "./FollowMode.scss";
interface FollowModeProps {
width: number;
height: number;
userToFollow: UserToFollow;
onDisconnect: () => void;
}
const FollowMode = ({
height,
width,
userToFollow,
onDisconnect,
}: FollowModeProps) => {
return (
<div style={{ position: "relative" }}>
<div className="follow-mode" style={{ width, height }}>
<div className="follow-mode__badge">
<div className="follow-mode__badge__label">
Following{" "}
<span
className="follow-mode__badge__username"
title={userToFollow.username}
>
{userToFollow.username}
</span>
</div>
<button
onClick={onDisconnect}
className="follow-mode__disconnect-btn"
>
{CloseIcon}
</button>
</div>
</div>
</div>
);
};
export default FollowMode;

View File

@ -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 {

View File

@ -19,7 +19,7 @@ export const SidebarTrigger = ({
const appState = useUIAppState();
return (
<label title={title}>
<label title={title} className="sidebar-trigger__label-element">
<input
className="ToolIcon_type_checkbox"
type="checkbox"

View File

@ -1,3 +1,5 @@
@import "../css/variables.module";
.excalidraw {
.UserList {
pointer-events: none;
@ -14,11 +16,13 @@
display: none;
}
// can fit max 5 avatars in a column
max-height: 140px;
box-sizing: border-box;
// can fit max 10 avatars in a row when there's enough space
max-width: 290px;
// can fit max 4 avatars (3 avatars + show more) in a column
max-height: 120px;
// can fit max 4 avatars (3 avatars + show more) when there's enough space
max-width: 120px;
// Tweak in 30px increments to fit more/fewer avatars in a row/column ^^
@ -33,5 +37,93 @@
padding: 0;
justify-content: normal;
margin: 0.5rem 0;
max-width: none;
max-height: none;
}
.UserList__more {
@include avatarStyles;
background-color: var(--color-gray-20);
border: 0 !important;
font-size: 0.5rem;
font-weight: 400;
flex-shrink: 0;
color: var(--color-gray-100);
}
--userlist-hint-bg-color: var(--color-gray-10);
--userlist-hint-heading-color: var(--color-gray-80);
--userlist-hint-text-color: var(--color-gray-60);
--userlist-collaborators-border-color: var(--color-gray-20);
&.theme--dark {
--userlist-hint-bg-color: var(--color-gray-90);
--userlist-hint-heading-color: var(--color-gray-30);
--userlist-hint-text-color: var(--color-gray-40);
--userlist-collaborators-border-color: var(--color-gray-80);
}
.UserList__collaborators {
position: static;
top: auto;
margin-top: 0;
max-height: 12rem;
overflow-y: auto;
padding: 0.25rem 0.5rem;
border-top: 1px solid var(--userlist-collaborators-border-color);
border-bottom: 1px solid var(--userlist-collaborators-border-color);
&__empty {
color: var(--color-gray-60);
font-size: 0.75rem;
line-height: 150%;
padding: 0.5rem 0;
}
}
.UserList__hint {
padding: 0.5rem 0.75rem;
overflow: hidden;
text-align: center;
color: var(--userlist-hint-text-color);
font-size: 0.75rem;
line-height: 150%;
}
.UserList__search-wrapper {
position: relative;
height: 2.5rem;
svg {
position: absolute;
top: 50%;
transform: translateY(-50%);
left: 0.75rem;
width: 1.25rem;
height: 1.25rem;
color: var(--color-gray-40);
z-index: 1;
}
}
.UserList__search {
position: absolute;
top: 0;
left: 0;
width: 100%;
box-sizing: border-box;
border: 0 !important;
border-radius: 0 !important;
font-size: 0.875rem;
padding-left: 2.5rem !important;
padding-right: 0.75rem !important;
&::placeholder {
color: var(--color-gray-40);
}
&:focus {
box-shadow: none !important;
}
}
}

View File

@ -2,51 +2,234 @@ import "./UserList.scss";
import React from "react";
import clsx from "clsx";
import { AppState, Collaborator } from "../types";
import { Collaborator, SocketId } from "../types";
import { Tooltip } from "./Tooltip";
import { useExcalidrawActionManager } from "./App";
import { ActionManager } from "../actions/manager";
export const UserList: React.FC<{
className?: string;
mobile?: boolean;
collaborators: AppState["collaborators"];
}> = ({ className, mobile, collaborators }) => {
const actionManager = useExcalidrawActionManager();
import * as Popover from "@radix-ui/react-popover";
import { Island } from "./Island";
import { searchIcon } from "./icons";
import { t } from "../i18n";
import { isShallowEqual } from "../utils";
const uniqueCollaborators = new Map<string, Collaborator>();
collaborators.forEach((collaborator, socketId) => {
uniqueCollaborators.set(
// filter on user id, else fall back on unique socketId
collaborator.id || socketId,
collaborator,
);
});
const FIRST_N_AVATARS = 3;
const SHOW_COLLABORATORS_FILTER_AT = 8;
const avatars =
uniqueCollaborators.size > 0 &&
Array.from(uniqueCollaborators)
.filter(([_, client]) => Object.keys(client).length !== 0)
.map(([clientId, collaborator]) => {
const avatarJSX = actionManager.renderAction("goToCollaborator", [
clientId,
collaborator,
]);
const ConditionalTooltipWrapper = ({
shouldWrap,
children,
clientId,
username,
}: {
shouldWrap: boolean;
children: React.ReactNode;
username?: string | null;
clientId: string;
}) =>
shouldWrap ? (
<Tooltip label={username || "Unknown user"} key={clientId}>
{children}
</Tooltip>
) : (
<React.Fragment key={clientId}>{children}</React.Fragment>
);
return mobile ? (
<Tooltip
label={collaborator.username || "Unknown user"}
key={clientId}
>
{avatarJSX}
</Tooltip>
) : (
<React.Fragment key={clientId}>{avatarJSX}</React.Fragment>
);
});
const renderCollaborator = ({
actionManager,
collaborator,
clientId,
withName = false,
shouldWrapWithTooltip = false,
}: {
actionManager: ActionManager;
collaborator: Collaborator;
clientId: string;
withName?: boolean;
shouldWrapWithTooltip?: boolean;
}) => {
const avatarJSX = actionManager.renderAction("goToCollaborator", [
clientId,
collaborator,
withName,
]);
return (
<div className={clsx("UserList", className, { UserList_mobile: mobile })}>
{avatars}
</div>
<ConditionalTooltipWrapper
key={clientId}
clientId={clientId}
username={collaborator.username}
shouldWrap={shouldWrapWithTooltip}
>
{avatarJSX}
</ConditionalTooltipWrapper>
);
};
type UserListUserObject = Pick<
Collaborator,
"avatarUrl" | "id" | "socketId" | "username"
>;
type UserListProps = {
className?: string;
mobile?: boolean;
collaborators: Map<SocketId, UserListUserObject>;
};
const collaboratorComparatorKeys = [
"avatarUrl",
"id",
"socketId",
"username",
] as const;
export const UserList = React.memo(
({ className, mobile, collaborators }: UserListProps) => {
const actionManager = useExcalidrawActionManager();
const uniqueCollaboratorsMap = new Map<string, Collaborator>();
collaborators.forEach((collaborator, socketId) => {
uniqueCollaboratorsMap.set(
// filter on user id, else fall back on unique socketId
collaborator.id || socketId,
{ ...collaborator, socketId },
);
});
// const uniqueCollaboratorsMap = sampleCollaborators;
const uniqueCollaboratorsArray = Array.from(uniqueCollaboratorsMap).filter(
([_, collaborator]) => Object.keys(collaborator).length !== 1,
);
const [searchTerm, setSearchTerm] = React.useState("");
if (uniqueCollaboratorsArray.length === 0) {
return null;
}
const searchTermNormalized = searchTerm.trim().toLowerCase();
const filteredCollaborators = searchTermNormalized
? uniqueCollaboratorsArray.filter(([, collaborator]) =>
collaborator.username?.toLowerCase().includes(searchTerm),
)
: uniqueCollaboratorsArray;
const firstNCollaborators = uniqueCollaboratorsArray.slice(
0,
FIRST_N_AVATARS,
);
const firstNAvatarsJSX = firstNCollaborators.map(
([clientId, collaborator]) =>
renderCollaborator({
actionManager,
collaborator,
clientId,
shouldWrapWithTooltip: true,
}),
);
return mobile ? (
<div className={clsx("UserList UserList_mobile", className)}>
{uniqueCollaboratorsArray.map(([clientId, collaborator]) =>
renderCollaborator({
actionManager,
collaborator,
clientId,
shouldWrapWithTooltip: true,
}),
)}
</div>
) : (
<div className={clsx("UserList", className)}>
{firstNAvatarsJSX}
{uniqueCollaboratorsArray.length > FIRST_N_AVATARS && (
<Popover.Root
onOpenChange={(isOpen) => {
if (!isOpen) {
setSearchTerm("");
}
}}
>
<Popover.Trigger className="UserList__more">
+{uniqueCollaboratorsArray.length - FIRST_N_AVATARS}
</Popover.Trigger>
<Popover.Content
style={{
zIndex: 2,
width: "12rem",
textAlign: "left",
}}
align="end"
sideOffset={10}
>
<Island style={{ overflow: "hidden" }}>
{uniqueCollaboratorsArray.length >=
SHOW_COLLABORATORS_FILTER_AT && (
<div className="UserList__search-wrapper">
{searchIcon}
<input
className="UserList__search"
type="text"
placeholder={t("userList.search.placeholder")}
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
}}
/>
</div>
)}
<div className="dropdown-menu UserList__collaborators">
{filteredCollaborators.length === 0 && (
<div className="UserList__collaborators__empty">
{t("userList.search.empty")}
</div>
)}
<div className="UserList__hint">
{t("userList.hint.text")}
</div>
{filteredCollaborators.map(([clientId, collaborator]) =>
renderCollaborator({
actionManager,
collaborator,
clientId,
withName: true,
}),
)}
</div>
</Island>
</Popover.Content>
</Popover.Root>
)}
</div>
);
},
(prev, next) => {
if (
prev.collaborators.size !== next.collaborators.size ||
prev.mobile !== next.mobile ||
prev.className !== next.className
) {
return false;
}
for (const [socketId, collaborator] of prev.collaborators) {
const nextCollaborator = next.collaborators.get(socketId);
if (
!nextCollaborator ||
!isShallowEqual(
collaborator,
nextCollaborator,
collaboratorComparatorKeys,
)
) {
return false;
}
}
return true;
},
);

View File

@ -1823,3 +1823,12 @@ export const brainIcon = createIcon(
</g>,
tablerIconProps,
);
export const searchIcon = createIcon(
<g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0" />
<path d="M21 21l-6 -6" />
</g>,
tablerIconProps,
);

View File

@ -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);

View File

@ -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);

View File

@ -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";

View File

@ -521,5 +521,14 @@
"description": "Currently only <flowchartLink>Flowchart</flowchartLink>,<sequenceLink> Sequence, </sequenceLink> and <classLink>Class </classLink>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"
}
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -41,7 +41,9 @@ import { Merge, ValueOf } from "./utility-types";
export type Point = Readonly<RoughPoint>;
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<string>;
}
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<{

View File

@ -771,11 +771,21 @@ export const queryFocusableElements = (container: HTMLElement | null) => {
export const isShallowEqual = <
T extends Record<string, any>,
I extends keyof T,
K extends readonly unknown[],
>(
objA: T,
objB: T,
comparators?: Record<I, (a: T[I], b: T[I]) => 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];

View File

@ -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,

View File

@ -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,