|
@@ -64,6 +64,8 @@ import {
|
|
|
MQ_MAX_HEIGHT_LANDSCAPE,
|
|
|
MQ_MAX_WIDTH_LANDSCAPE,
|
|
|
MQ_MAX_WIDTH_PORTRAIT,
|
|
|
+ MQ_RIGHT_SIDEBAR_MIN_WIDTH,
|
|
|
+ MQ_SM_MAX_WIDTH,
|
|
|
POINTER_BUTTON,
|
|
|
SCROLL_TIMEOUT,
|
|
|
TAP_TWICE_TIMEOUT,
|
|
@@ -194,7 +196,7 @@ import {
|
|
|
LibraryItems,
|
|
|
PointerDownState,
|
|
|
SceneData,
|
|
|
- DeviceType,
|
|
|
+ Device,
|
|
|
} from "../types";
|
|
|
import {
|
|
|
debounce,
|
|
@@ -220,7 +222,6 @@ import {
|
|
|
} from "../utils";
|
|
|
import ContextMenu, { ContextMenuOption } from "./ContextMenu";
|
|
|
import LayerUI from "./LayerUI";
|
|
|
-import { Stats } from "./Stats";
|
|
|
import { Toast } from "./Toast";
|
|
|
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
|
|
import {
|
|
@@ -259,12 +260,14 @@ import {
|
|
|
isLocalLink,
|
|
|
} from "../element/Hyperlink";
|
|
|
|
|
|
-const defaultDeviceTypeContext: DeviceType = {
|
|
|
+const deviceContextInitialValue = {
|
|
|
+ isSmScreen: false,
|
|
|
isMobile: false,
|
|
|
isTouchScreen: false,
|
|
|
+ canDeviceFitSidebar: false,
|
|
|
};
|
|
|
-const DeviceTypeContext = React.createContext(defaultDeviceTypeContext);
|
|
|
-export const useDeviceType = () => useContext(DeviceTypeContext);
|
|
|
+const DeviceContext = React.createContext<Device>(deviceContextInitialValue);
|
|
|
+export const useDevice = () => useContext<Device>(DeviceContext);
|
|
|
const ExcalidrawContainerContext = React.createContext<{
|
|
|
container: HTMLDivElement | null;
|
|
|
id: string | null;
|
|
@@ -296,10 +299,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
rc: RoughCanvas | null = null;
|
|
|
unmounted: boolean = false;
|
|
|
actionManager: ActionManager;
|
|
|
- deviceType: DeviceType = {
|
|
|
- isMobile: false,
|
|
|
- isTouchScreen: false,
|
|
|
- };
|
|
|
+ device: Device = deviceContextInitialValue;
|
|
|
detachIsMobileMqHandler?: () => void;
|
|
|
|
|
|
private excalidrawContainerRef = React.createRef<HTMLDivElement>();
|
|
@@ -353,12 +353,12 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
width: window.innerWidth,
|
|
|
height: window.innerHeight,
|
|
|
showHyperlinkPopup: false,
|
|
|
+ isLibraryMenuDocked: false,
|
|
|
};
|
|
|
|
|
|
this.id = nanoid();
|
|
|
|
|
|
this.library = new Library(this);
|
|
|
-
|
|
|
if (excalidrawRef) {
|
|
|
const readyPromise =
|
|
|
("current" in excalidrawRef && excalidrawRef.current?.readyPromise) ||
|
|
@@ -485,7 +485,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
<div
|
|
|
className={clsx("excalidraw excalidraw-container", {
|
|
|
"excalidraw--view-mode": viewModeEnabled,
|
|
|
- "excalidraw--mobile": this.deviceType.isMobile,
|
|
|
+ "excalidraw--mobile": this.device.isMobile,
|
|
|
})}
|
|
|
ref={this.excalidrawContainerRef}
|
|
|
onDrop={this.handleAppOnDrop}
|
|
@@ -497,7 +497,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
<ExcalidrawContainerContext.Provider
|
|
|
value={this.excalidrawContainerValue}
|
|
|
>
|
|
|
- <DeviceTypeContext.Provider value={this.deviceType}>
|
|
|
+ <DeviceContext.Provider value={this.device}>
|
|
|
<LayerUI
|
|
|
canvas={this.canvas}
|
|
|
appState={this.state}
|
|
@@ -521,6 +521,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
isCollaborating={this.props.isCollaborating}
|
|
|
renderTopRightUI={renderTopRightUI}
|
|
|
renderCustomFooter={renderFooter}
|
|
|
+ renderCustomStats={renderCustomStats}
|
|
|
viewModeEnabled={viewModeEnabled}
|
|
|
showExitZenModeBtn={
|
|
|
typeof this.props?.zenModeEnabled === "undefined" &&
|
|
@@ -548,15 +549,6 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
onLinkOpen={this.props.onLinkOpen}
|
|
|
/>
|
|
|
)}
|
|
|
- {this.state.showStats && (
|
|
|
- <Stats
|
|
|
- appState={this.state}
|
|
|
- setAppState={this.setAppState}
|
|
|
- elements={this.scene.getNonDeletedElements()}
|
|
|
- onClose={this.toggleStats}
|
|
|
- renderCustomStats={renderCustomStats}
|
|
|
- />
|
|
|
- )}
|
|
|
{this.state.toastMessage !== null && (
|
|
|
<Toast
|
|
|
message={this.state.toastMessage}
|
|
@@ -564,7 +556,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
/>
|
|
|
)}
|
|
|
<main>{this.renderCanvas()}</main>
|
|
|
- </DeviceTypeContext.Provider>
|
|
|
+ </DeviceContext.Provider>
|
|
|
</ExcalidrawContainerContext.Provider>
|
|
|
</div>
|
|
|
);
|
|
@@ -763,7 +755,12 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
const scene = restore(initialData, null, null);
|
|
|
scene.appState = {
|
|
|
...scene.appState,
|
|
|
- isLibraryOpen: this.state.isLibraryOpen,
|
|
|
+ // we're falling back to current (pre-init) state when deciding
|
|
|
+ // whether to open the library, to handle a case where we
|
|
|
+ // update the state outside of initialData (e.g. when loading the app
|
|
|
+ // with a library install link, which should auto-open the library)
|
|
|
+ isLibraryOpen:
|
|
|
+ initialData?.appState?.isLibraryOpen || this.state.isLibraryOpen,
|
|
|
activeTool:
|
|
|
scene.appState.activeTool.type === "image"
|
|
|
? { ...scene.appState.activeTool, type: "selection" }
|
|
@@ -794,6 +791,21 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
});
|
|
|
};
|
|
|
|
|
|
+ private refreshDeviceState = (container: HTMLDivElement) => {
|
|
|
+ const { width, height } = container.getBoundingClientRect();
|
|
|
+ const sidebarBreakpoint =
|
|
|
+ this.props.UIOptions.dockedSidebarBreakpoint != null
|
|
|
+ ? this.props.UIOptions.dockedSidebarBreakpoint
|
|
|
+ : MQ_RIGHT_SIDEBAR_MIN_WIDTH;
|
|
|
+ this.device = updateObject(this.device, {
|
|
|
+ isSmScreen: width < MQ_SM_MAX_WIDTH,
|
|
|
+ isMobile:
|
|
|
+ width < MQ_MAX_WIDTH_PORTRAIT ||
|
|
|
+ (height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE),
|
|
|
+ canDeviceFitSidebar: width > sidebarBreakpoint,
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
public async componentDidMount() {
|
|
|
this.unmounted = false;
|
|
|
this.excalidrawContainerValue.container =
|
|
@@ -835,34 +847,53 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
this.focusContainer();
|
|
|
}
|
|
|
|
|
|
+ if (
|
|
|
+ this.excalidrawContainerRef.current &&
|
|
|
+ // bounding rects don't work in tests so updating
|
|
|
+ // the state on init would result in making the test enviro run
|
|
|
+ // in mobile breakpoint (0 width/height), making everything fail
|
|
|
+ process.env.NODE_ENV !== "test"
|
|
|
+ ) {
|
|
|
+ this.refreshDeviceState(this.excalidrawContainerRef.current);
|
|
|
+ }
|
|
|
+
|
|
|
if ("ResizeObserver" in window && this.excalidrawContainerRef?.current) {
|
|
|
this.resizeObserver = new ResizeObserver(() => {
|
|
|
- // compute isMobile state
|
|
|
+ // recompute device dimensions state
|
|
|
// ---------------------------------------------------------------------
|
|
|
- const { width, height } =
|
|
|
- this.excalidrawContainerRef.current!.getBoundingClientRect();
|
|
|
- this.deviceType = updateObject(this.deviceType, {
|
|
|
- isMobile:
|
|
|
- width < MQ_MAX_WIDTH_PORTRAIT ||
|
|
|
- (height < MQ_MAX_HEIGHT_LANDSCAPE &&
|
|
|
- width < MQ_MAX_WIDTH_LANDSCAPE),
|
|
|
- });
|
|
|
+ this.refreshDeviceState(this.excalidrawContainerRef.current!);
|
|
|
// refresh offsets
|
|
|
// ---------------------------------------------------------------------
|
|
|
this.updateDOMRect();
|
|
|
});
|
|
|
this.resizeObserver?.observe(this.excalidrawContainerRef.current);
|
|
|
} else if (window.matchMedia) {
|
|
|
- const mediaQuery = window.matchMedia(
|
|
|
+ const mdScreenQuery = window.matchMedia(
|
|
|
`(max-width: ${MQ_MAX_WIDTH_PORTRAIT}px), (max-height: ${MQ_MAX_HEIGHT_LANDSCAPE}px) and (max-width: ${MQ_MAX_WIDTH_LANDSCAPE}px)`,
|
|
|
);
|
|
|
+ const smScreenQuery = window.matchMedia(
|
|
|
+ `(max-width: ${MQ_SM_MAX_WIDTH}px)`,
|
|
|
+ );
|
|
|
+ const canDeviceFitSidebarMediaQuery = window.matchMedia(
|
|
|
+ `(min-width: ${
|
|
|
+ // NOTE this won't update if a different breakpoint is supplied
|
|
|
+ // after mount
|
|
|
+ this.props.UIOptions.dockedSidebarBreakpoint != null
|
|
|
+ ? this.props.UIOptions.dockedSidebarBreakpoint
|
|
|
+ : MQ_RIGHT_SIDEBAR_MIN_WIDTH
|
|
|
+ }px)`,
|
|
|
+ );
|
|
|
const handler = () => {
|
|
|
- this.deviceType = updateObject(this.deviceType, {
|
|
|
- isMobile: mediaQuery.matches,
|
|
|
+ this.excalidrawContainerRef.current!.getBoundingClientRect();
|
|
|
+ this.device = updateObject(this.device, {
|
|
|
+ isSmScreen: smScreenQuery.matches,
|
|
|
+ isMobile: mdScreenQuery.matches,
|
|
|
+ canDeviceFitSidebar: canDeviceFitSidebarMediaQuery.matches,
|
|
|
});
|
|
|
};
|
|
|
- mediaQuery.addListener(handler);
|
|
|
- this.detachIsMobileMqHandler = () => mediaQuery.removeListener(handler);
|
|
|
+ mdScreenQuery.addListener(handler);
|
|
|
+ this.detachIsMobileMqHandler = () =>
|
|
|
+ mdScreenQuery.removeListener(handler);
|
|
|
}
|
|
|
|
|
|
const searchParams = new URLSearchParams(window.location.search.slice(1));
|
|
@@ -1004,6 +1035,14 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
|
|
|
componentDidUpdate(prevProps: AppProps, prevState: AppState) {
|
|
|
if (
|
|
|
+ this.excalidrawContainerRef.current &&
|
|
|
+ prevProps.UIOptions.dockedSidebarBreakpoint !==
|
|
|
+ this.props.UIOptions.dockedSidebarBreakpoint
|
|
|
+ ) {
|
|
|
+ this.refreshDeviceState(this.excalidrawContainerRef.current);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (
|
|
|
prevState.scrollX !== this.state.scrollX ||
|
|
|
prevState.scrollY !== this.state.scrollY
|
|
|
) {
|
|
@@ -1175,7 +1214,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
theme: this.state.theme,
|
|
|
imageCache: this.imageCache,
|
|
|
isExporting: false,
|
|
|
- renderScrollbars: !this.deviceType.isMobile,
|
|
|
+ renderScrollbars: !this.device.isMobile,
|
|
|
},
|
|
|
);
|
|
|
|
|
@@ -1453,11 +1492,15 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
|
|
|
this.scene.replaceAllElements(nextElements);
|
|
|
this.history.resumeRecording();
|
|
|
+
|
|
|
this.setState(
|
|
|
selectGroupsForSelectedElements(
|
|
|
{
|
|
|
...this.state,
|
|
|
- isLibraryOpen: false,
|
|
|
+ isLibraryOpen:
|
|
|
+ this.state.isLibraryOpen && this.device.canDeviceFitSidebar
|
|
|
+ ? this.state.isLibraryMenuDocked
|
|
|
+ : false,
|
|
|
selectedElementIds: newElements.reduce((map, element) => {
|
|
|
if (!isBoundToContainer(element)) {
|
|
|
map[element.id] = true;
|
|
@@ -1529,7 +1572,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
trackEvent(
|
|
|
"toolbar",
|
|
|
"toggleLock",
|
|
|
- `${source} (${this.deviceType.isMobile ? "mobile" : "desktop"})`,
|
|
|
+ `${source} (${this.device.isMobile ? "mobile" : "desktop"})`,
|
|
|
);
|
|
|
}
|
|
|
this.setState((prevState) => {
|
|
@@ -1560,10 +1603,6 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
this.actionManager.executeAction(actionToggleZenMode);
|
|
|
};
|
|
|
|
|
|
- toggleStats = () => {
|
|
|
- this.actionManager.executeAction(actionToggleStats);
|
|
|
- };
|
|
|
-
|
|
|
scrollToContent = (
|
|
|
target:
|
|
|
| ExcalidrawElement
|
|
@@ -1721,7 +1760,16 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
}
|
|
|
|
|
|
if (event.code === CODES.ZERO) {
|
|
|
- this.setState({ isLibraryOpen: !this.state.isLibraryOpen });
|
|
|
+ const nextState = !this.state.isLibraryOpen;
|
|
|
+ this.setState({ isLibraryOpen: nextState });
|
|
|
+ // track only openings
|
|
|
+ if (nextState) {
|
|
|
+ trackEvent(
|
|
|
+ "library",
|
|
|
+ "toggleLibrary (open)",
|
|
|
+ `keyboard (${this.device.isMobile ? "mobile" : "desktop"})`,
|
|
|
+ );
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
if (isArrowKey(event.key)) {
|
|
@@ -1815,7 +1863,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
trackEvent(
|
|
|
"toolbar",
|
|
|
shape,
|
|
|
- `keyboard (${this.deviceType.isMobile ? "mobile" : "desktop"})`,
|
|
|
+ `keyboard (${this.device.isMobile ? "mobile" : "desktop"})`,
|
|
|
);
|
|
|
}
|
|
|
this.setActiveTool({ type: shape });
|
|
@@ -2440,7 +2488,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
element,
|
|
|
this.state,
|
|
|
[scenePointer.x, scenePointer.y],
|
|
|
- this.deviceType.isMobile,
|
|
|
+ this.device.isMobile,
|
|
|
)
|
|
|
);
|
|
|
});
|
|
@@ -2472,7 +2520,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
this.hitLinkElement,
|
|
|
this.state,
|
|
|
[lastPointerDownCoords.x, lastPointerDownCoords.y],
|
|
|
- this.deviceType.isMobile,
|
|
|
+ this.device.isMobile,
|
|
|
);
|
|
|
const lastPointerUpCoords = viewportCoordsToSceneCoords(
|
|
|
this.lastPointerUp!,
|
|
@@ -2482,7 +2530,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
this.hitLinkElement,
|
|
|
this.state,
|
|
|
[lastPointerUpCoords.x, lastPointerUpCoords.y],
|
|
|
- this.deviceType.isMobile,
|
|
|
+ this.device.isMobile,
|
|
|
);
|
|
|
if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
|
|
|
const url = this.hitLinkElement.link;
|
|
@@ -2921,10 +2969,10 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
- !this.deviceType.isTouchScreen &&
|
|
|
+ !this.device.isTouchScreen &&
|
|
|
["pen", "touch"].includes(event.pointerType)
|
|
|
) {
|
|
|
- this.deviceType = updateObject(this.deviceType, { isTouchScreen: true });
|
|
|
+ this.device = updateObject(this.device, { isTouchScreen: true });
|
|
|
}
|
|
|
|
|
|
if (isPanning) {
|
|
@@ -3066,7 +3114,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
event: React.PointerEvent<HTMLCanvasElement>,
|
|
|
) => {
|
|
|
this.lastPointerUp = event;
|
|
|
- if (this.deviceType.isTouchScreen) {
|
|
|
+ if (this.device.isTouchScreen) {
|
|
|
const scenePointer = viewportCoordsToSceneCoords(
|
|
|
{ clientX: event.clientX, clientY: event.clientY },
|
|
|
this.state,
|
|
@@ -3084,7 +3132,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
this.hitLinkElement &&
|
|
|
!this.state.selectedElementIds[this.hitLinkElement.id]
|
|
|
) {
|
|
|
- this.redirectToLink(event, this.deviceType.isTouchScreen);
|
|
|
+ this.redirectToLink(event, this.device.isTouchScreen);
|
|
|
}
|
|
|
|
|
|
this.removePointer(event);
|
|
@@ -3456,7 +3504,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
pointerDownState.hit.element,
|
|
|
this.state,
|
|
|
[pointerDownState.origin.x, pointerDownState.origin.y],
|
|
|
- this.deviceType.isMobile,
|
|
|
+ this.device.isMobile,
|
|
|
)
|
|
|
) {
|
|
|
return false;
|
|
@@ -5563,7 +5611,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
} else {
|
|
|
ContextMenu.push({
|
|
|
options: [
|
|
|
- this.deviceType.isMobile &&
|
|
|
+ this.device.isMobile &&
|
|
|
navigator.clipboard && {
|
|
|
trackEvent: false,
|
|
|
name: "paste",
|
|
@@ -5575,7 +5623,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
},
|
|
|
contextItemLabel: "labels.paste",
|
|
|
},
|
|
|
- this.deviceType.isMobile && navigator.clipboard && separator,
|
|
|
+ this.device.isMobile && navigator.clipboard && separator,
|
|
|
probablySupportsClipboardBlob &&
|
|
|
elements.length > 0 &&
|
|
|
actionCopyAsPng,
|
|
@@ -5620,9 +5668,9 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
} else {
|
|
|
ContextMenu.push({
|
|
|
options: [
|
|
|
- this.deviceType.isMobile && actionCut,
|
|
|
- this.deviceType.isMobile && navigator.clipboard && actionCopy,
|
|
|
- this.deviceType.isMobile &&
|
|
|
+ this.device.isMobile && actionCut,
|
|
|
+ this.device.isMobile && navigator.clipboard && actionCopy,
|
|
|
+ this.device.isMobile &&
|
|
|
navigator.clipboard && {
|
|
|
name: "paste",
|
|
|
trackEvent: false,
|
|
@@ -5634,7 +5682,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
},
|
|
|
contextItemLabel: "labels.paste",
|
|
|
},
|
|
|
- this.deviceType.isMobile && separator,
|
|
|
+ this.device.isMobile && separator,
|
|
|
...options,
|
|
|
separator,
|
|
|
actionCopyStyles,
|