Compare commits
17 Commits
master
...
barnabasmo
Author | SHA1 | Date | |
---|---|---|---|
|
6354501cca | ||
|
33b78c35ea | ||
|
fdd3cd5e79 | ||
|
d7b7a6715e | ||
|
8062bd1027 | ||
|
ada5ddc675 | ||
|
b4867cb3dc | ||
|
a576b0e3b5 | ||
|
d695b4044d | ||
|
22ad63f967 | ||
|
bdf0c8c67c | ||
|
898564bc2e | ||
|
1777b4566c | ||
|
a9cfd97cc4 | ||
|
f255a0835f | ||
|
42a90def41 | ||
|
9152ce24f2 |
@ -108,6 +108,9 @@ export const actionZoomIn = register({
|
|||||||
},
|
},
|
||||||
appState,
|
appState,
|
||||||
),
|
),
|
||||||
|
userToFollow: appState.shouldDisconnectFollowModeOnCanvasInteraction
|
||||||
|
? null
|
||||||
|
: appState.userToFollow,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@ -145,6 +148,9 @@ export const actionZoomOut = register({
|
|||||||
},
|
},
|
||||||
appState,
|
appState,
|
||||||
),
|
),
|
||||||
|
userToFollow: appState.shouldDisconnectFollowModeOnCanvasInteraction
|
||||||
|
? null
|
||||||
|
: appState.userToFollow,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@ -182,6 +188,9 @@ export const actionResetZoom = register({
|
|||||||
},
|
},
|
||||||
appState,
|
appState,
|
||||||
),
|
),
|
||||||
|
userToFollow: appState.shouldDisconnectFollowModeOnCanvasInteraction
|
||||||
|
? null
|
||||||
|
: appState.userToFollow,
|
||||||
},
|
},
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
@ -225,22 +234,20 @@ const zoomValueToFitBoundsOnViewport = (
|
|||||||
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const zoomToFit = ({
|
export const zoomToFitBounds = ({
|
||||||
targetElements,
|
bounds,
|
||||||
appState,
|
appState,
|
||||||
fitToViewport = false,
|
fitToViewport = false,
|
||||||
viewportZoomFactor = 0.7,
|
viewportZoomFactor = 0.7,
|
||||||
}: {
|
}: {
|
||||||
targetElements: readonly ExcalidrawElement[];
|
bounds: readonly [number, number, number, number];
|
||||||
appState: Readonly<AppState>;
|
appState: Readonly<AppState>;
|
||||||
/** whether to fit content to viewport (beyond >100%) */
|
/** whether to fit content to viewport (beyond >100%) */
|
||||||
fitToViewport: boolean;
|
fitToViewport: boolean;
|
||||||
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
||||||
viewportZoomFactor?: number;
|
viewportZoomFactor?: number;
|
||||||
}) => {
|
}) => {
|
||||||
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
|
const [x1, y1, x2, y2] = bounds;
|
||||||
|
|
||||||
const [x1, y1, x2, y2] = commonBounds;
|
|
||||||
const centerX = (x1 + x2) / 2;
|
const centerX = (x1 + x2) / 2;
|
||||||
const centerY = (y1 + y2) / 2;
|
const centerY = (y1 + y2) / 2;
|
||||||
|
|
||||||
@ -267,7 +274,7 @@ export const zoomToFit = ({
|
|||||||
scrollX = (appState.width / 2) * (1 / newZoomValue) - centerX;
|
scrollX = (appState.width / 2) * (1 / newZoomValue) - centerX;
|
||||||
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
|
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
|
||||||
} else {
|
} else {
|
||||||
newZoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
|
newZoomValue = zoomValueToFitBoundsOnViewport(bounds, {
|
||||||
width: appState.width,
|
width: appState.width,
|
||||||
height: appState.height,
|
height: appState.height,
|
||||||
});
|
});
|
||||||
@ -296,6 +303,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
|
// Note, this action differs from actionZoomToFitSelection in that it doesn't
|
||||||
// zoom beyond 100%. In other words, if the content is smaller than viewport
|
// zoom beyond 100%. In other words, if the content is smaller than viewport
|
||||||
// size, it won't be zoomed in.
|
// size, it won't be zoomed in.
|
||||||
@ -306,7 +336,12 @@ export const actionZoomToFitSelectionInViewport = register({
|
|||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
return zoomToFit({
|
return zoomToFit({
|
||||||
targetElements: selectedElements.length ? selectedElements : elements,
|
targetElements: selectedElements.length ? selectedElements : elements,
|
||||||
appState,
|
appState: {
|
||||||
|
...appState,
|
||||||
|
userToFollow: appState.shouldDisconnectFollowModeOnCanvasInteraction
|
||||||
|
? null
|
||||||
|
: appState.userToFollow,
|
||||||
|
},
|
||||||
fitToViewport: false,
|
fitToViewport: false,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -326,7 +361,12 @@ export const actionZoomToFitSelection = register({
|
|||||||
const selectedElements = app.scene.getSelectedElements(appState);
|
const selectedElements = app.scene.getSelectedElements(appState);
|
||||||
return zoomToFit({
|
return zoomToFit({
|
||||||
targetElements: selectedElements.length ? selectedElements : elements,
|
targetElements: selectedElements.length ? selectedElements : elements,
|
||||||
appState,
|
appState: {
|
||||||
|
...appState,
|
||||||
|
userToFollow: appState.shouldDisconnectFollowModeOnCanvasInteraction
|
||||||
|
? null
|
||||||
|
: appState.userToFollow,
|
||||||
|
},
|
||||||
fitToViewport: true,
|
fitToViewport: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -343,7 +383,16 @@ export const actionZoomToFit = register({
|
|||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "canvas" },
|
trackEvent: { category: "canvas" },
|
||||||
perform: (elements, appState) =>
|
perform: (elements, appState) =>
|
||||||
zoomToFit({ targetElements: elements, appState, fitToViewport: false }),
|
zoomToFit({
|
||||||
|
targetElements: elements,
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
userToFollow: appState.shouldDisconnectFollowModeOnCanvasInteraction
|
||||||
|
? null
|
||||||
|
: appState.userToFollow,
|
||||||
|
},
|
||||||
|
fitToViewport: false,
|
||||||
|
}),
|
||||||
keyTest: (event) =>
|
keyTest: (event) =>
|
||||||
event.code === CODES.ONE &&
|
event.code === CODES.ONE &&
|
||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
|
@ -9,14 +9,30 @@ export const actionGoToCollaborator = register({
|
|||||||
viewMode: true,
|
viewMode: true,
|
||||||
trackEvent: { category: "collab" },
|
trackEvent: { category: "collab" },
|
||||||
perform: (_elements, appState, value) => {
|
perform: (_elements, appState, value) => {
|
||||||
const point = value as Collaborator["pointer"];
|
const _value = value as Collaborator & { clientId: string };
|
||||||
|
const point = _value.pointer;
|
||||||
|
|
||||||
if (!point) {
|
if (!point) {
|
||||||
return { appState, commitToHistory: false };
|
return { appState, commitToHistory: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (appState.userToFollow?.clientId === _value.clientId) {
|
||||||
|
return {
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
userToFollow: null,
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: {
|
appState: {
|
||||||
...appState,
|
...appState,
|
||||||
|
userToFollow: {
|
||||||
|
clientId: _value.clientId,
|
||||||
|
username: _value.username || "",
|
||||||
|
},
|
||||||
...centerScrollOn({
|
...centerScrollOn({
|
||||||
scenePoint: point,
|
scenePoint: point,
|
||||||
viewportDimensions: {
|
viewportDimensions: {
|
||||||
@ -31,17 +47,36 @@ export const actionGoToCollaborator = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ updateData, data }) => {
|
PanelComponent: ({ updateData, data, appState }) => {
|
||||||
const [clientId, collaborator] = data as [string, Collaborator];
|
const [clientId, collaborator, withName] = data as [
|
||||||
|
string,
|
||||||
|
Collaborator,
|
||||||
|
boolean,
|
||||||
|
];
|
||||||
|
|
||||||
const background = getClientColor(clientId);
|
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?.clientId === clientId}
|
||||||
|
/>
|
||||||
|
{collaborator.username}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Avatar
|
<Avatar
|
||||||
color={background}
|
color={background}
|
||||||
onClick={() => updateData(collaborator.pointer)}
|
onClick={() => updateData({ ...collaborator, clientId })}
|
||||||
name={collaborator.username || ""}
|
name={collaborator.username || ""}
|
||||||
src={collaborator.avatarUrl}
|
src={collaborator.avatarUrl}
|
||||||
|
isBeingFollowed={appState.userToFollow?.clientId === clientId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -98,6 +98,9 @@ export const getDefaultAppState = (): Omit<
|
|||||||
pendingImageElementId: null,
|
pendingImageElementId: null,
|
||||||
showHyperlinkPopup: false,
|
showHyperlinkPopup: false,
|
||||||
selectedLinearElement: null,
|
selectedLinearElement: null,
|
||||||
|
userToFollow: null,
|
||||||
|
shouldDisconnectFollowModeOnCanvasInteraction: true,
|
||||||
|
amIBeingFollowed: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -204,6 +207,13 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
pendingImageElementId: { browser: false, export: false, server: false },
|
pendingImageElementId: { browser: false, export: false, server: false },
|
||||||
showHyperlinkPopup: { browser: false, export: false, server: false },
|
showHyperlinkPopup: { browser: false, export: false, server: false },
|
||||||
selectedLinearElement: { browser: true, export: false, server: false },
|
selectedLinearElement: { browser: true, export: false, server: false },
|
||||||
|
userToFollow: { browser: false, export: false, server: false },
|
||||||
|
shouldDisconnectFollowModeOnCanvasInteraction: {
|
||||||
|
browser: false,
|
||||||
|
export: false,
|
||||||
|
server: false,
|
||||||
|
},
|
||||||
|
amIBeingFollowed: { browser: false, export: false, server: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <
|
const _clearAppStateForStorage = <
|
||||||
|
@ -331,6 +331,7 @@ import { actionWrapTextInContainer } from "../actions/actionBoundText";
|
|||||||
import BraveMeasureTextError from "./BraveMeasureTextError";
|
import BraveMeasureTextError from "./BraveMeasureTextError";
|
||||||
import { activeEyeDropperAtom } from "./EyeDropper";
|
import { activeEyeDropperAtom } from "./EyeDropper";
|
||||||
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
||||||
|
import FollowMode from "./FollowMode/FollowMode";
|
||||||
|
|
||||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||||
@ -547,14 +548,57 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
} = this.state;
|
} = this.state;
|
||||||
const canvasWidth = canvasDOMWidth * canvasScale;
|
const canvasWidth = canvasDOMWidth * canvasScale;
|
||||||
const canvasHeight = canvasDOMHeight * canvasScale;
|
const canvasHeight = canvasDOMHeight * canvasScale;
|
||||||
|
|
||||||
|
const userToFollow = this.state.userToFollow;
|
||||||
|
|
||||||
if (viewModeEnabled) {
|
if (viewModeEnabled) {
|
||||||
return (
|
return (
|
||||||
|
<FollowMode
|
||||||
|
width={canvasDOMWidth}
|
||||||
|
height={canvasDOMHeight}
|
||||||
|
userToFollow={userToFollow}
|
||||||
|
onDisconnect={() => {
|
||||||
|
this.setState({ userToFollow: null });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
className="excalidraw__canvas"
|
||||||
|
style={{
|
||||||
|
width: canvasDOMWidth,
|
||||||
|
height: canvasDOMHeight,
|
||||||
|
cursor: CURSOR_TYPE.GRAB,
|
||||||
|
}}
|
||||||
|
width={canvasWidth}
|
||||||
|
height={canvasHeight}
|
||||||
|
ref={this.handleCanvasRef}
|
||||||
|
onContextMenu={(event: React.PointerEvent<HTMLCanvasElement>) =>
|
||||||
|
this.handleCanvasContextMenu(event)
|
||||||
|
}
|
||||||
|
onPointerMove={this.handleCanvasPointerMove}
|
||||||
|
onPointerUp={this.handleCanvasPointerUp}
|
||||||
|
onPointerCancel={this.removePointer}
|
||||||
|
onTouchMove={this.handleTouchMove}
|
||||||
|
onPointerDown={this.handleCanvasPointerDown}
|
||||||
|
>
|
||||||
|
{t("labels.drawingCanvas")}
|
||||||
|
</canvas>
|
||||||
|
</FollowMode>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<FollowMode
|
||||||
|
width={canvasDOMWidth}
|
||||||
|
height={canvasDOMHeight}
|
||||||
|
userToFollow={userToFollow}
|
||||||
|
onDisconnect={() => {
|
||||||
|
this.setState({ userToFollow: null });
|
||||||
|
}}
|
||||||
|
>
|
||||||
<canvas
|
<canvas
|
||||||
className="excalidraw__canvas"
|
className="excalidraw__canvas"
|
||||||
style={{
|
style={{
|
||||||
width: canvasDOMWidth,
|
width: canvasDOMWidth,
|
||||||
height: canvasDOMHeight,
|
height: canvasDOMHeight,
|
||||||
cursor: CURSOR_TYPE.GRAB,
|
|
||||||
}}
|
}}
|
||||||
width={canvasWidth}
|
width={canvasWidth}
|
||||||
height={canvasHeight}
|
height={canvasHeight}
|
||||||
@ -562,38 +606,16 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
onContextMenu={(event: React.PointerEvent<HTMLCanvasElement>) =>
|
onContextMenu={(event: React.PointerEvent<HTMLCanvasElement>) =>
|
||||||
this.handleCanvasContextMenu(event)
|
this.handleCanvasContextMenu(event)
|
||||||
}
|
}
|
||||||
|
onPointerDown={this.handleCanvasPointerDown}
|
||||||
|
onDoubleClick={this.handleCanvasDoubleClick}
|
||||||
onPointerMove={this.handleCanvasPointerMove}
|
onPointerMove={this.handleCanvasPointerMove}
|
||||||
onPointerUp={this.handleCanvasPointerUp}
|
onPointerUp={this.handleCanvasPointerUp}
|
||||||
onPointerCancel={this.removePointer}
|
onPointerCancel={this.removePointer}
|
||||||
onTouchMove={this.handleTouchMove}
|
onTouchMove={this.handleTouchMove}
|
||||||
onPointerDown={this.handleCanvasPointerDown}
|
|
||||||
>
|
>
|
||||||
{t("labels.drawingCanvas")}
|
{t("labels.drawingCanvas")}
|
||||||
</canvas>
|
</canvas>
|
||||||
);
|
</FollowMode>
|
||||||
}
|
|
||||||
return (
|
|
||||||
<canvas
|
|
||||||
className="excalidraw__canvas"
|
|
||||||
style={{
|
|
||||||
width: canvasDOMWidth,
|
|
||||||
height: canvasDOMHeight,
|
|
||||||
}}
|
|
||||||
width={canvasWidth}
|
|
||||||
height={canvasHeight}
|
|
||||||
ref={this.handleCanvasRef}
|
|
||||||
onContextMenu={(event: React.PointerEvent<HTMLCanvasElement>) =>
|
|
||||||
this.handleCanvasContextMenu(event)
|
|
||||||
}
|
|
||||||
onPointerDown={this.handleCanvasPointerDown}
|
|
||||||
onDoubleClick={this.handleCanvasDoubleClick}
|
|
||||||
onPointerMove={this.handleCanvasPointerMove}
|
|
||||||
onPointerUp={this.handleCanvasPointerUp}
|
|
||||||
onPointerCancel={this.removePointer}
|
|
||||||
onTouchMove={this.handleTouchMove}
|
|
||||||
>
|
|
||||||
{t("labels.drawingCanvas")}
|
|
||||||
</canvas>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1500,13 +1522,37 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.refreshDeviceState(this.excalidrawContainerRef.current);
|
this.refreshDeviceState(this.excalidrawContainerRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const hasScrollChanged =
|
||||||
prevState.scrollX !== this.state.scrollX ||
|
prevState.scrollX !== this.state.scrollX ||
|
||||||
prevState.scrollY !== this.state.scrollY
|
prevState.scrollY !== this.state.scrollY;
|
||||||
) {
|
|
||||||
|
if (hasScrollChanged) {
|
||||||
this.props?.onScrollChange?.(this.state.scrollX, this.state.scrollY);
|
this.props?.onScrollChange?.(this.state.scrollX, this.state.scrollY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (prevState.zoom.value !== this.state.zoom.value || hasScrollChanged) {
|
||||||
|
this.props?.onScrollAndZoomChange?.({
|
||||||
|
zoom: this.state.zoom,
|
||||||
|
scroll: { x: this.state.scrollX, y: this.state.scrollY },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevState.userToFollow !== this.state.userToFollow) {
|
||||||
|
if (prevState.userToFollow) {
|
||||||
|
this.props?.onUserFollowed?.({
|
||||||
|
userToFollow: prevState.userToFollow,
|
||||||
|
action: "unfollow",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.userToFollow) {
|
||||||
|
this.props?.onUserFollowed?.({
|
||||||
|
userToFollow: this.state.userToFollow,
|
||||||
|
action: "follow",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
Object.keys(this.state.selectedElementIds).length &&
|
Object.keys(this.state.selectedElementIds).length &&
|
||||||
isEraserActive(this.state)
|
isEraserActive(this.state)
|
||||||
@ -2365,7 +2411,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
state,
|
state,
|
||||||
) => {
|
) => {
|
||||||
this.cancelInProgresAnimation?.();
|
this.cancelInProgresAnimation?.();
|
||||||
this.setState(state);
|
|
||||||
|
// potentially unfollow participant
|
||||||
|
this.setState((prevState, props) => ({
|
||||||
|
...(typeof state === "function" ? state(prevState, props) : state),
|
||||||
|
userToFollow: prevState.shouldDisconnectFollowModeOnCanvasInteraction
|
||||||
|
? null
|
||||||
|
: prevState.userToFollow,
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
setToast = (
|
setToast = (
|
||||||
@ -3980,6 +4033,12 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
private handleCanvasPointerDown = (
|
private handleCanvasPointerDown = (
|
||||||
event: React.PointerEvent<HTMLElement>,
|
event: React.PointerEvent<HTMLElement>,
|
||||||
) => {
|
) => {
|
||||||
|
this.setState((prevState) => ({
|
||||||
|
userToFollow: prevState.shouldDisconnectFollowModeOnCanvasInteraction
|
||||||
|
? null
|
||||||
|
: prevState.userToFollow,
|
||||||
|
}));
|
||||||
|
|
||||||
// since contextMenu options are potentially evaluated on each render,
|
// since contextMenu options are potentially evaluated on each render,
|
||||||
// and an contextMenu action may depend on selection state, we must
|
// and an contextMenu action may depend on selection state, we must
|
||||||
// close the contextMenu before we update the selection on pointerDown
|
// close the contextMenu before we update the selection on pointerDown
|
||||||
|
@ -2,34 +2,6 @@
|
|||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.Avatar {
|
.Avatar {
|
||||||
width: 1.25rem;
|
@include avatarStyles;
|
||||||
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%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,21 +2,33 @@ import "./Avatar.scss";
|
|||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { getNameInitial } from "../clients";
|
import { getNameInitial } from "../clients";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
type AvatarProps = {
|
type AvatarProps = {
|
||||||
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||||
color: string;
|
color: string;
|
||||||
name: string;
|
name: string;
|
||||||
src?: 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 shortName = getNameInitial(name);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const loadImg = !error && src;
|
const loadImg = !error && src;
|
||||||
const style = loadImg ? undefined : { background: color };
|
const style = loadImg ? undefined : { background: color };
|
||||||
return (
|
return (
|
||||||
<div className="Avatar" style={style} onClick={onClick}>
|
<div
|
||||||
|
className={clsx("Avatar", { "Avatar--is-followed": isBeingFollowed })}
|
||||||
|
style={style}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
{loadImg ? (
|
{loadImg ? (
|
||||||
<img
|
<img
|
||||||
className="Avatar-img"
|
className="Avatar-img"
|
||||||
|
59
src/components/FollowMode/FollowMode.scss
Normal file
59
src/components/FollowMode/FollowMode.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
50
src/components/FollowMode/FollowMode.tsx
Normal file
50
src/components/FollowMode/FollowMode.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { UserToFollow } from "../../types";
|
||||||
|
import { CloseIcon } from "../icons";
|
||||||
|
import "./FollowMode.scss";
|
||||||
|
|
||||||
|
interface FollowModeProps {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
children: React.ReactNode;
|
||||||
|
userToFollow?: UserToFollow | null;
|
||||||
|
onDisconnect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FollowMode = ({
|
||||||
|
children,
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
userToFollow,
|
||||||
|
onDisconnect,
|
||||||
|
}: FollowModeProps) => {
|
||||||
|
if (!userToFollow) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FollowMode;
|
@ -22,6 +22,10 @@
|
|||||||
width: var(--lg-icon-size);
|
width: var(--lg-icon-size);
|
||||||
height: var(--lg-icon-size);
|
height: var(--lg-icon-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__label-element {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.default-sidebar-trigger .sidebar-trigger__label {
|
.default-sidebar-trigger .sidebar-trigger__label {
|
||||||
|
@ -19,7 +19,7 @@ export const SidebarTrigger = ({
|
|||||||
const appState = useUIAppState();
|
const appState = useUIAppState();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label title={title}>
|
<label title={title} className="sidebar-trigger__label-element">
|
||||||
<input
|
<input
|
||||||
className="ToolIcon_type_checkbox"
|
className="ToolIcon_type_checkbox"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
@import "../css/variables.module";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.UserList {
|
.UserList {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@ -14,11 +16,13 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
// can fit max 5 avatars in a column
|
box-sizing: border-box;
|
||||||
max-height: 140px;
|
|
||||||
|
|
||||||
// can fit max 10 avatars in a row when there's enough space
|
// can fit max 4 avatars (3 avatars + show more) in a column
|
||||||
max-width: 290px;
|
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 ^^
|
// Tweak in 30px increments to fit more/fewer avatars in a row/column ^^
|
||||||
|
|
||||||
@ -33,5 +37,104 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
justify-content: normal;
|
justify-content: normal;
|
||||||
margin: 0.5rem 0;
|
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-70);
|
||||||
|
}
|
||||||
|
|
||||||
|
--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 {
|
||||||
|
background-color: var(--userlist-hint-bg-color);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 0 0 var(--border-radius-lg) var(--border-radius-lg);
|
||||||
|
|
||||||
|
&-heading {
|
||||||
|
color: var(--userlist-hint-heading-color);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 150%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-text {
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,48 +5,262 @@ import clsx from "clsx";
|
|||||||
import { AppState, Collaborator } from "../types";
|
import { AppState, Collaborator } from "../types";
|
||||||
import { Tooltip } from "./Tooltip";
|
import { Tooltip } from "./Tooltip";
|
||||||
import { useExcalidrawActionManager } from "./App";
|
import { useExcalidrawActionManager } from "./App";
|
||||||
|
import { ActionManager } from "../actions/manager";
|
||||||
|
|
||||||
export const UserList: React.FC<{
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
import { Island } from "./Island";
|
||||||
|
import { searchIcon } from "./icons";
|
||||||
|
import { t } from "../i18n";
|
||||||
|
|
||||||
|
// TODO follow-participant
|
||||||
|
// can be used for debugging styling, filter, etc
|
||||||
|
// don't forget to remove it before shipping
|
||||||
|
const sampleCollaborators = new Map([
|
||||||
|
[
|
||||||
|
"client-id-1",
|
||||||
|
{
|
||||||
|
username: "John Doe",
|
||||||
|
color: "#1CA6FC",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"client-id-2",
|
||||||
|
{
|
||||||
|
username: "Jane Doe",
|
||||||
|
color: "#FEA3AA",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"client-id-3",
|
||||||
|
{
|
||||||
|
username: "Kate Doe",
|
||||||
|
color: "#B2F2BB",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"client-id-4",
|
||||||
|
{
|
||||||
|
username: "Handsome Swan",
|
||||||
|
color: "#FFDBAB",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"client-id-5",
|
||||||
|
{
|
||||||
|
username: "Brilliant Chameleon",
|
||||||
|
color: "#E2E2E2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"client-id-6",
|
||||||
|
{
|
||||||
|
username: "Jill Doe",
|
||||||
|
color: "#FCCB5F",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"client-id-7",
|
||||||
|
{
|
||||||
|
username: "Jack Doe",
|
||||||
|
color: "#BCE784",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"client-id-8",
|
||||||
|
{
|
||||||
|
username: "Jolly Doe",
|
||||||
|
color: "#5DD39E",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]) as any as Map<string, Collaborator>;
|
||||||
|
|
||||||
|
const FIRST_N_AVATARS = 3;
|
||||||
|
const SHOW_COLLABORATORS_FILTER_AT = 6;
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ConditionalTooltipWrapper
|
||||||
|
key={clientId}
|
||||||
|
clientId={clientId}
|
||||||
|
username={collaborator.username}
|
||||||
|
shouldWrap={shouldWrapWithTooltip}
|
||||||
|
>
|
||||||
|
{avatarJSX}
|
||||||
|
</ConditionalTooltipWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UserList = ({
|
||||||
|
className,
|
||||||
|
mobile,
|
||||||
|
collaborators,
|
||||||
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
mobile?: boolean;
|
mobile?: boolean;
|
||||||
collaborators: AppState["collaborators"];
|
collaborators: AppState["collaborators"];
|
||||||
}> = ({ className, mobile, collaborators }) => {
|
}) => {
|
||||||
const actionManager = useExcalidrawActionManager();
|
const actionManager = useExcalidrawActionManager();
|
||||||
|
|
||||||
const uniqueCollaborators = new Map<string, Collaborator>();
|
const uniqueCollaboratorsMap = React.useMemo(() => {
|
||||||
collaborators.forEach((collaborator, socketId) => {
|
const map = new Map<string, Collaborator>();
|
||||||
uniqueCollaborators.set(
|
collaborators.forEach((collaborator, socketId) => {
|
||||||
// filter on user id, else fall back on unique socketId
|
map.set(
|
||||||
collaborator.id || socketId,
|
// filter on user id, else fall back on unique socketId
|
||||||
|
collaborator.id || socketId,
|
||||||
|
collaborator,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [collaborators]);
|
||||||
|
|
||||||
|
// const uniqueCollaboratorsMap = sampleCollaborators;
|
||||||
|
const uniqueCollaboratorsArray = React.useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(uniqueCollaboratorsMap).filter(
|
||||||
|
([_, collaborator]) => Object.keys(collaborator).length !== 0,
|
||||||
|
),
|
||||||
|
[uniqueCollaboratorsMap],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [searchTerm, setSearchTerm] = React.useState("");
|
||||||
|
|
||||||
|
const filteredCollaborators = React.useMemo(
|
||||||
|
() =>
|
||||||
|
uniqueCollaboratorsArray.filter(([, collaborator]) =>
|
||||||
|
collaborator.username?.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
|
),
|
||||||
|
[uniqueCollaboratorsArray, searchTerm],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uniqueCollaboratorsArray.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstNCollaborators = uniqueCollaboratorsArray.slice(
|
||||||
|
0,
|
||||||
|
FIRST_N_AVATARS,
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstNAvatarsJSX = firstNCollaborators.map(([clientId, collaborator]) =>
|
||||||
|
renderCollaborator({
|
||||||
|
actionManager,
|
||||||
collaborator,
|
collaborator,
|
||||||
);
|
clientId,
|
||||||
});
|
shouldWrapWithTooltip: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const avatars =
|
return mobile ? (
|
||||||
uniqueCollaborators.size > 0 &&
|
<div className={clsx("UserList UserList_mobile", className)}>
|
||||||
Array.from(uniqueCollaborators)
|
{uniqueCollaboratorsArray.map(([clientId, collaborator]) =>
|
||||||
.filter(([_, client]) => Object.keys(client).length !== 0)
|
renderCollaborator({
|
||||||
.map(([clientId, collaborator]) => {
|
actionManager,
|
||||||
const avatarJSX = actionManager.renderAction("goToCollaborator", [
|
|
||||||
clientId,
|
|
||||||
collaborator,
|
collaborator,
|
||||||
]);
|
clientId,
|
||||||
|
shouldWrapWithTooltip: true,
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={clsx("UserList", className)}>
|
||||||
|
{firstNAvatarsJSX}
|
||||||
|
|
||||||
return mobile ? (
|
{uniqueCollaboratorsArray.length > FIRST_N_AVATARS && (
|
||||||
<Tooltip
|
<Popover.Root
|
||||||
label={collaborator.username || "Unknown user"}
|
onOpenChange={(isOpen) => {
|
||||||
key={clientId}
|
if (!isOpen) {
|
||||||
|
setSearchTerm("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popover.Trigger className="UserList__more">
|
||||||
|
+{uniqueCollaboratorsArray.length - FIRST_N_AVATARS}
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Content
|
||||||
|
style={{ zIndex: 2, maxWidth: "14rem", textAlign: "left" }}
|
||||||
|
align="end"
|
||||||
|
sideOffset={10}
|
||||||
>
|
>
|
||||||
{avatarJSX}
|
<Island style={{ overflow: "hidden" }}>
|
||||||
</Tooltip>
|
{SHOW_COLLABORATORS_FILTER_AT <=
|
||||||
) : (
|
uniqueCollaboratorsArray.length && (
|
||||||
<React.Fragment key={clientId}>{avatarJSX}</React.Fragment>
|
<div className="UserList__search-wrapper">
|
||||||
);
|
{searchIcon}
|
||||||
});
|
<input
|
||||||
|
className="UserList__search"
|
||||||
return (
|
type="text"
|
||||||
<div className={clsx("UserList", className, { UserList_mobile: mobile })}>
|
placeholder={t("userList.search.placeholder")}
|
||||||
{avatars}
|
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>
|
||||||
|
)}
|
||||||
|
{filteredCollaborators.map(([clientId, collaborator]) =>
|
||||||
|
renderCollaborator({
|
||||||
|
actionManager,
|
||||||
|
collaborator,
|
||||||
|
clientId,
|
||||||
|
withName: true,
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="UserList__hint">
|
||||||
|
<div className="UserList__hint-heading">
|
||||||
|
{t("userList.hint.heading")}
|
||||||
|
</div>
|
||||||
|
<div className="UserList__hint-text">
|
||||||
|
{t("userList.hint.text")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Island>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1647,3 +1647,12 @@ export const frameToolIcon = createIcon(
|
|||||||
</g>,
|
</g>,
|
||||||
tablerIconProps,
|
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,
|
||||||
|
);
|
||||||
|
@ -81,6 +81,7 @@
|
|||||||
--color-primary-darkest: #4a47b1;
|
--color-primary-darkest: #4a47b1;
|
||||||
--color-primary-light: #e3e2fe;
|
--color-primary-light: #e3e2fe;
|
||||||
--color-primary-light-darker: #d7d5ff;
|
--color-primary-light-darker: #d7d5ff;
|
||||||
|
--color-primary-hover: #5753d0;
|
||||||
|
|
||||||
--color-gray-10: #f5f5f5;
|
--color-gray-10: #f5f5f5;
|
||||||
--color-gray-20: #ebebeb;
|
--color-gray-20: #ebebeb;
|
||||||
@ -193,6 +194,7 @@
|
|||||||
--color-primary-darkest: #beb9ff;
|
--color-primary-darkest: #beb9ff;
|
||||||
--color-primary-light: #4f4d6f;
|
--color-primary-light: #4f4d6f;
|
||||||
--color-primary-light-darker: #43415e;
|
--color-primary-light-darker: #43415e;
|
||||||
|
--color-primary-hover: #bbb8ff;
|
||||||
|
|
||||||
--color-text-warning: var(--color-gray-80);
|
--color-text-warning: var(--color-gray-80);
|
||||||
|
|
||||||
|
@ -104,6 +104,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
|
||||||
|
&-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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$theme-filter: "invert(93%) hue-rotate(180deg)";
|
$theme-filter: "invert(93%) hue-rotate(180deg)";
|
||||||
$right-sidebar-width: "302px";
|
$right-sidebar-width: "302px";
|
||||||
|
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
import throttle from "lodash.throttle";
|
import throttle from "lodash.throttle";
|
||||||
import { PureComponent } from "react";
|
import { PureComponent } from "react";
|
||||||
import { ExcalidrawImperativeAPI } from "../../types";
|
import {
|
||||||
|
AppState,
|
||||||
|
ExcalidrawImperativeAPI,
|
||||||
|
OnUserFollowedPayload,
|
||||||
|
} from "../../types";
|
||||||
import { ErrorDialog } from "../../components/ErrorDialog";
|
import { ErrorDialog } from "../../components/ErrorDialog";
|
||||||
import { APP_NAME, ENV, EVENT } from "../../constants";
|
import { APP_NAME, ENV, EVENT } from "../../constants";
|
||||||
import { ImportedDataState } from "../../data/types";
|
import { ImportedDataState } from "../../data/types";
|
||||||
@ -16,6 +20,7 @@ import { Collaborator, Gesture } from "../../types";
|
|||||||
import {
|
import {
|
||||||
preventUnload,
|
preventUnload,
|
||||||
resolvablePromise,
|
resolvablePromise,
|
||||||
|
viewportCoordsToSceneCoords,
|
||||||
withBatchedUpdates,
|
withBatchedUpdates,
|
||||||
} from "../../utils";
|
} from "../../utils";
|
||||||
import {
|
import {
|
||||||
@ -71,6 +76,7 @@ import { resetBrowserStateVersions } from "../data/tabSync";
|
|||||||
import { LocalData } from "../data/LocalData";
|
import { LocalData } from "../data/LocalData";
|
||||||
import { atom, useAtom } from "jotai";
|
import { atom, useAtom } from "jotai";
|
||||||
import { appJotaiStore } from "../app-jotai";
|
import { appJotaiStore } from "../app-jotai";
|
||||||
|
import { zoomToFitBounds } from "../../actions/actionCanvas";
|
||||||
|
|
||||||
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
export const collabAPIAtom = atom<CollabAPI | null>(null);
|
||||||
export const collabDialogShownAtom = atom(false);
|
export const collabDialogShownAtom = atom(false);
|
||||||
@ -89,6 +95,8 @@ export interface CollabAPI {
|
|||||||
/** function so that we can access the latest value from stale callbacks */
|
/** function so that we can access the latest value from stale callbacks */
|
||||||
isCollaborating: () => boolean;
|
isCollaborating: () => boolean;
|
||||||
onPointerUpdate: CollabInstance["onPointerUpdate"];
|
onPointerUpdate: CollabInstance["onPointerUpdate"];
|
||||||
|
onScrollAndZoomChange: CollabInstance["onScrollAndZoomChange"];
|
||||||
|
onUserFollowed: CollabInstance["onUserFollowed"];
|
||||||
startCollaboration: CollabInstance["startCollaboration"];
|
startCollaboration: CollabInstance["startCollaboration"];
|
||||||
stopCollaboration: CollabInstance["stopCollaboration"];
|
stopCollaboration: CollabInstance["stopCollaboration"];
|
||||||
syncElements: CollabInstance["syncElements"];
|
syncElements: CollabInstance["syncElements"];
|
||||||
@ -162,6 +170,8 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
const collabAPI: CollabAPI = {
|
const collabAPI: CollabAPI = {
|
||||||
isCollaborating: this.isCollaborating,
|
isCollaborating: this.isCollaborating,
|
||||||
onPointerUpdate: this.onPointerUpdate,
|
onPointerUpdate: this.onPointerUpdate,
|
||||||
|
onScrollAndZoomChange: this.onScrollAndZoomChange,
|
||||||
|
onUserFollowed: this.onUserFollowed,
|
||||||
startCollaboration: this.startCollaboration,
|
startCollaboration: this.startCollaboration,
|
||||||
syncElements: this.syncElements,
|
syncElements: this.syncElements,
|
||||||
fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
|
fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
|
||||||
@ -513,6 +523,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
case "MOUSE_LOCATION": {
|
case "MOUSE_LOCATION": {
|
||||||
const { pointer, button, username, selectedElementIds } =
|
const { pointer, button, username, selectedElementIds } =
|
||||||
decryptedData.payload;
|
decryptedData.payload;
|
||||||
|
|
||||||
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
|
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
|
||||||
decryptedData.payload.socketId ||
|
decryptedData.payload.socketId ||
|
||||||
// @ts-ignore legacy, see #2094 (#2097)
|
// @ts-ignore legacy, see #2094 (#2097)
|
||||||
@ -530,6 +541,23 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "SCROLL_AND_ZOOM": {
|
||||||
|
const { bounds } = decryptedData.payload;
|
||||||
|
|
||||||
|
const _appState = this.excalidrawAPI.getAppState();
|
||||||
|
|
||||||
|
const { appState } = zoomToFitBounds({
|
||||||
|
appState: _appState,
|
||||||
|
bounds,
|
||||||
|
fitToViewport: true,
|
||||||
|
viewportZoomFactor: 1,
|
||||||
|
});
|
||||||
|
this.excalidrawAPI.updateScene({ appState });
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "IDLE_STATUS": {
|
case "IDLE_STATUS": {
|
||||||
const { userState, socketId, username } = decryptedData.payload;
|
const { userState, socketId, username } = decryptedData.payload;
|
||||||
const collaborators = new Map(this.collaborators);
|
const collaborators = new Map(this.collaborators);
|
||||||
@ -556,6 +584,18 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
scenePromise.resolve(sceneData);
|
scenePromise.resolve(sceneData);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.portal.socket.on("broadcast-follow", () => {
|
||||||
|
this.excalidrawAPI.updateScene({
|
||||||
|
appState: { amIBeingFollowed: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.portal.socket.on("broadcast-unfollow", () => {
|
||||||
|
this.excalidrawAPI.updateScene({
|
||||||
|
appState: { amIBeingFollowed: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
this.initializeIdleDetector();
|
this.initializeIdleDetector();
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
@ -763,6 +803,46 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||||||
CURSOR_SYNC_TIMEOUT,
|
CURSOR_SYNC_TIMEOUT,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onScrollAndZoomChange = throttle(
|
||||||
|
(payload: { zoom: AppState["zoom"]; scroll: { x: number; y: number } }) => {
|
||||||
|
const appState = this.excalidrawAPI.getAppState();
|
||||||
|
|
||||||
|
if (appState.amIBeingFollowed) {
|
||||||
|
const { x: x1, y: y1 } = viewportCoordsToSceneCoords(
|
||||||
|
{ clientX: 0, clientY: 0 },
|
||||||
|
{
|
||||||
|
offsetLeft: appState.offsetLeft,
|
||||||
|
offsetTop: appState.offsetTop,
|
||||||
|
scrollX: payload.scroll.x,
|
||||||
|
scrollY: payload.scroll.y,
|
||||||
|
zoom: payload.zoom,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { x: x2, y: y2 } = viewportCoordsToSceneCoords(
|
||||||
|
{ clientX: appState.width, clientY: appState.height },
|
||||||
|
{
|
||||||
|
offsetLeft: appState.offsetLeft,
|
||||||
|
offsetTop: appState.offsetTop,
|
||||||
|
scrollX: payload.scroll.x,
|
||||||
|
scrollY: payload.scroll.y,
|
||||||
|
zoom: payload.zoom,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.portal.socket &&
|
||||||
|
this.portal.broadcastScrollAndZoom(
|
||||||
|
{ bounds: [x1, y1, x2, y2] },
|
||||||
|
`follow_${this.portal.socket.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onUserFollowed = (payload: OnUserFollowedPayload) => {
|
||||||
|
this.portal.socket && this.portal.broadcastUserFollowed(payload);
|
||||||
|
};
|
||||||
|
|
||||||
onIdleStateChange = (userState: UserIdleState) => {
|
onIdleStateChange = (userState: UserIdleState) => {
|
||||||
this.portal.broadcastIdleChange(userState);
|
this.portal.broadcastIdleChange(userState);
|
||||||
};
|
};
|
||||||
|
@ -12,7 +12,7 @@ import {
|
|||||||
FILE_UPLOAD_TIMEOUT,
|
FILE_UPLOAD_TIMEOUT,
|
||||||
WS_SCENE_EVENT_TYPES,
|
WS_SCENE_EVENT_TYPES,
|
||||||
} from "../app_constants";
|
} from "../app_constants";
|
||||||
import { UserIdleState } from "../../types";
|
import { OnUserFollowedPayload, UserIdleState } from "../../types";
|
||||||
import { trackEvent } from "../../analytics";
|
import { trackEvent } from "../../analytics";
|
||||||
import throttle from "lodash.throttle";
|
import throttle from "lodash.throttle";
|
||||||
import { newElementWith } from "../../element/mutateElement";
|
import { newElementWith } from "../../element/mutateElement";
|
||||||
@ -83,6 +83,7 @@ class Portal {
|
|||||||
async _broadcastSocketData(
|
async _broadcastSocketData(
|
||||||
data: SocketUpdateData,
|
data: SocketUpdateData,
|
||||||
volatile: boolean = false,
|
volatile: boolean = false,
|
||||||
|
roomId?: string,
|
||||||
) {
|
) {
|
||||||
if (this.isOpen()) {
|
if (this.isOpen()) {
|
||||||
const json = JSON.stringify(data);
|
const json = JSON.stringify(data);
|
||||||
@ -91,7 +92,7 @@ class Portal {
|
|||||||
|
|
||||||
this.socket?.emit(
|
this.socket?.emit(
|
||||||
volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER,
|
volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER,
|
||||||
this.roomId,
|
roomId ?? this.roomId,
|
||||||
encryptedBuffer,
|
encryptedBuffer,
|
||||||
iv,
|
iv,
|
||||||
);
|
);
|
||||||
@ -213,12 +214,43 @@ class Portal {
|
|||||||
username: this.collab.state.username,
|
username: this.collab.state.username,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return this._broadcastSocketData(
|
return this._broadcastSocketData(
|
||||||
data as SocketUpdateData,
|
data as SocketUpdateData,
|
||||||
true, // volatile
|
true, // volatile
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
broadcastScrollAndZoom = (
|
||||||
|
payload: {
|
||||||
|
bounds: [number, number, number, number];
|
||||||
|
},
|
||||||
|
roomId: string,
|
||||||
|
) => {
|
||||||
|
if (this.socket?.id) {
|
||||||
|
const data: SocketUpdateDataSource["SCROLL_AND_ZOOM"] = {
|
||||||
|
type: "SCROLL_AND_ZOOM",
|
||||||
|
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;
|
export default Portal;
|
||||||
|
@ -113,6 +113,14 @@ export type SocketUpdateDataSource = {
|
|||||||
username: string;
|
username: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
SCROLL_AND_ZOOM: {
|
||||||
|
type: "SCROLL_AND_ZOOM";
|
||||||
|
payload: {
|
||||||
|
socketId: string;
|
||||||
|
username: string;
|
||||||
|
bounds: [number, number, number, number];
|
||||||
|
};
|
||||||
|
};
|
||||||
IDLE_STATUS: {
|
IDLE_STATUS: {
|
||||||
type: "IDLE_STATUS";
|
type: "IDLE_STATUS";
|
||||||
payload: {
|
payload: {
|
||||||
|
@ -649,6 +649,12 @@ const ExcalidrawWrapper = () => {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Excalidraw
|
<Excalidraw
|
||||||
|
onScrollAndZoomChange={({ zoom, scroll }) => {
|
||||||
|
collabAPI?.onScrollAndZoomChange({ zoom, scroll });
|
||||||
|
}}
|
||||||
|
onUserFollowed={(userToFollow) => {
|
||||||
|
collabAPI?.onUserFollowed(userToFollow);
|
||||||
|
}}
|
||||||
ref={excalidrawRefCallback}
|
ref={excalidrawRefCallback}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
initialData={initialStatePromiseRef.current.promise}
|
initialData={initialStatePromiseRef.current.promise}
|
||||||
|
@ -480,5 +480,15 @@
|
|||||||
"description": "Loading external drawing will <bold>replace your existing content</bold>.<br></br>You can back up your drawing first by using one of the options below."
|
"description": "Loading external drawing will <bold>replace your existing content</bold>.<br></br>You can back up your drawing first by using one of the options below."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"userList": {
|
||||||
|
"search": {
|
||||||
|
"placeholder": "Quick search",
|
||||||
|
"empty": "No users found"
|
||||||
|
},
|
||||||
|
"hint": {
|
||||||
|
"heading": "Follow mode",
|
||||||
|
"text": "You can click on a user to toggle follow mode. In follow mode, their canvas movements will be mirrored on your canvas."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -41,6 +41,8 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
|||||||
onLinkOpen,
|
onLinkOpen,
|
||||||
onPointerDown,
|
onPointerDown,
|
||||||
onScrollChange,
|
onScrollChange,
|
||||||
|
onScrollAndZoomChange,
|
||||||
|
onUserFollowed,
|
||||||
children,
|
children,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
@ -115,6 +117,8 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
|||||||
onLinkOpen={onLinkOpen}
|
onLinkOpen={onLinkOpen}
|
||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
onScrollChange={onScrollChange}
|
onScrollChange={onScrollChange}
|
||||||
|
onScrollAndZoomChange={onScrollAndZoomChange}
|
||||||
|
onUserFollowed={onUserFollowed}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</App>
|
</App>
|
||||||
@ -249,3 +253,5 @@ export { LiveCollaborationTrigger };
|
|||||||
export { DefaultSidebar } from "../../components/DefaultSidebar";
|
export { DefaultSidebar } from "../../components/DefaultSidebar";
|
||||||
|
|
||||||
export { normalizeLink } from "../../data/url";
|
export { normalizeLink } from "../../data/url";
|
||||||
|
|
||||||
|
export { zoomToFitBounds } from "../../actions/actionCanvas";
|
||||||
|
21
src/types.ts
21
src/types.ts
@ -98,6 +98,8 @@ export type LastActiveTool =
|
|||||||
export type SidebarName = string;
|
export type SidebarName = string;
|
||||||
export type SidebarTabName = string;
|
export type SidebarTabName = string;
|
||||||
|
|
||||||
|
export type UserToFollow = { clientId: string; username: string };
|
||||||
|
|
||||||
export type AppState = {
|
export type AppState = {
|
||||||
contextMenu: {
|
contextMenu: {
|
||||||
items: ContextMenuItems;
|
items: ContextMenuItems;
|
||||||
@ -223,6 +225,12 @@ export type AppState = {
|
|||||||
pendingImageElementId: ExcalidrawImageElement["id"] | null;
|
pendingImageElementId: ExcalidrawImageElement["id"] | null;
|
||||||
showHyperlinkPopup: false | "info" | "editor";
|
showHyperlinkPopup: false | "info" | "editor";
|
||||||
selectedLinearElement: LinearElementEditor | null;
|
selectedLinearElement: LinearElementEditor | null;
|
||||||
|
/** the user's clientId who is being followed on the canvas */
|
||||||
|
userToFollow: UserToFollow | null;
|
||||||
|
/** whether follow mode should be disconnected when the non-remote user interacts with the canvas */
|
||||||
|
shouldDisconnectFollowModeOnCanvasInteraction: boolean;
|
||||||
|
/** whether the user is being followed on the canvas */
|
||||||
|
amIBeingFollowed: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UIAppState = Omit<
|
export type UIAppState = Omit<
|
||||||
@ -307,6 +315,11 @@ export type ExcalidrawInitialDataState = Merge<
|
|||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type OnUserFollowedPayload = {
|
||||||
|
userToFollow: UserToFollow;
|
||||||
|
action: "follow" | "unfollow";
|
||||||
|
};
|
||||||
|
|
||||||
export interface ExcalidrawProps {
|
export interface ExcalidrawProps {
|
||||||
onChange?: (
|
onChange?: (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@ -360,6 +373,14 @@ export interface ExcalidrawProps {
|
|||||||
pointerDownState: PointerDownState,
|
pointerDownState: PointerDownState,
|
||||||
) => void;
|
) => void;
|
||||||
onScrollChange?: (scrollX: number, scrollY: number) => void;
|
onScrollChange?: (scrollX: number, scrollY: number) => void;
|
||||||
|
onScrollAndZoomChange?: ({
|
||||||
|
zoom,
|
||||||
|
scroll,
|
||||||
|
}: {
|
||||||
|
zoom: Zoom;
|
||||||
|
scroll: { x: number; y: number };
|
||||||
|
}) => void;
|
||||||
|
onUserFollowed?: (payload: OnUserFollowedPayload) => void;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user