diff --git a/src/actions/actionExport.tsx b/src/actions/actionExport.tsx index dda8569c1c..7ae69db980 100644 --- a/src/actions/actionExport.tsx +++ b/src/actions/actionExport.tsx @@ -7,7 +7,7 @@ import { DarkModeToggle } from "../components/DarkModeToggle"; import { loadFromJSON, saveAsJSON } from "../data"; import { resaveAsImageWithScene } from "../data/resave"; import { t } from "../i18n"; -import { useDeviceType } from "../components/App"; +import { useDevice } from "../components/App"; import { KEYS } from "../keys"; import { register } from "./register"; import { CheckboxItem } from "../components/CheckboxItem"; @@ -204,7 +204,7 @@ export const actionSaveFileToDisk = register({ icon={saveAs} title={t("buttons.saveAs")} aria-label={t("buttons.saveAs")} - showAriaLabel={useDeviceType().isMobile} + showAriaLabel={useDevice().isMobile} hidden={!nativeFileSystemSupported} onClick={() => updateData(null)} data-testid="save-as-button" @@ -248,7 +248,7 @@ export const actionLoadScene = register({ icon={load} title={t("buttons.load")} aria-label={t("buttons.load")} - showAriaLabel={useDeviceType().isMobile} + showAriaLabel={useDevice().isMobile} onClick={updateData} data-testid="load-button" /> diff --git a/src/actions/manager.tsx b/src/actions/manager.tsx index 7481d56a35..246bfe7a6c 100644 --- a/src/actions/manager.tsx +++ b/src/actions/manager.tsx @@ -30,7 +30,7 @@ const trackAction = ( trackEvent( action.trackEvent.category, action.trackEvent.action || action.name, - `${source} (${app.deviceType.isMobile ? "mobile" : "desktop"})`, + `${source} (${app.device.isMobile ? "mobile" : "desktop"})`, ); } } diff --git a/src/appState.ts b/src/appState.ts index c320ffc1cd..879d0590f3 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -58,6 +58,7 @@ export const getDefaultAppState = (): Omit< gridSize: null, isBindingEnabled: true, isLibraryOpen: false, + isLibraryMenuDocked: false, isLoading: false, isResizing: false, isRotating: false, @@ -146,7 +147,8 @@ const APP_STATE_STORAGE_CONF = (< gridSize: { browser: true, export: true, server: true }, height: { browser: false, export: false, server: false }, isBindingEnabled: { browser: false, export: false, server: false }, - isLibraryOpen: { browser: false, export: false, server: false }, + isLibraryOpen: { browser: true, export: false, server: false }, + isLibraryMenuDocked: { browser: true, export: false, server: false }, isLoading: { browser: false, export: false, server: false }, isResizing: { browser: false, export: false, server: false }, isRotating: { browser: false, export: false, server: false }, diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index 29eddbe045..897bc09aa0 100644 --- a/src/components/Actions.tsx +++ b/src/components/Actions.tsx @@ -3,7 +3,7 @@ import { ActionManager } from "../actions/manager"; import { getNonDeletedElements } from "../element"; import { ExcalidrawElement, PointerType } from "../element/types"; import { t } from "../i18n"; -import { useDeviceType } from "../components/App"; +import { useDevice } from "../components/App"; import { canChangeSharpness, canHaveArrowheads, @@ -52,7 +52,7 @@ export const SelectedShapeActions = ({ isSingleElementBoundContainer = true; } const isEditing = Boolean(appState.editingElement); - const deviceType = useDeviceType(); + const device = useDevice(); const isRTL = document.documentElement.getAttribute("dir") === "rtl"; const showFillIcons = @@ -177,8 +177,8 @@ export const SelectedShapeActions = ({
{t("labels.actions")}
- {!deviceType.isMobile && renderAction("duplicateSelection")} - {!deviceType.isMobile && renderAction("deleteSelectedElements")} + {!device.isMobile && renderAction("duplicateSelection")} + {!device.isMobile && renderAction("deleteSelectedElements")} {renderAction("group")} {renderAction("ungroup")} {showLinkIcon && renderAction("hyperlink")} diff --git a/src/components/App.tsx b/src/components/App.tsx index 4c36f60275..4a861f085e 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -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(deviceContextInitialValue); +export const useDevice = () => useContext(DeviceContext); const ExcalidrawContainerContext = React.createContext<{ container: HTMLDivElement | null; id: string | null; @@ -296,10 +299,7 @@ class App extends React.Component { rc: RoughCanvas | null = null; unmounted: boolean = false; actionManager: ActionManager; - deviceType: DeviceType = { - isMobile: false, - isTouchScreen: false, - }; + device: Device = deviceContextInitialValue; detachIsMobileMqHandler?: () => void; private excalidrawContainerRef = React.createRef(); @@ -353,12 +353,12 @@ class App extends React.Component { 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 {
{ - + { 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 { onLinkOpen={this.props.onLinkOpen} /> )} - {this.state.showStats && ( - - )} {this.state.toastMessage !== null && ( { /> )}
{this.renderCanvas()}
-
+
); @@ -763,7 +755,12 @@ class App extends React.Component { 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 { }); }; + 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 { 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)); @@ -1003,6 +1034,14 @@ class App extends React.Component { } 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 { 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 { 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 { 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 { this.actionManager.executeAction(actionToggleZenMode); }; - toggleStats = () => { - this.actionManager.executeAction(actionToggleStats); - }; - scrollToContent = ( target: | ExcalidrawElement @@ -1721,7 +1760,16 @@ class App extends React.Component { } 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 { 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 { element, this.state, [scenePointer.x, scenePointer.y], - this.deviceType.isMobile, + this.device.isMobile, ) ); }); @@ -2472,7 +2520,7 @@ class App extends React.Component { 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 { 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 { } 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 { event: React.PointerEvent, ) => { 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 { 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 { 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 { } 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 { }, 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 { } 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 { }, contextItemLabel: "labels.paste", }, - this.deviceType.isMobile && separator, + this.device.isMobile && separator, ...options, separator, actionCopyStyles, diff --git a/src/components/ClearCanvas.tsx b/src/components/ClearCanvas.tsx index 6d25a4a1d9..ab1cd6704f 100644 --- a/src/components/ClearCanvas.tsx +++ b/src/components/ClearCanvas.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { t } from "../i18n"; -import { useDeviceType } from "./App"; +import { useDevice } from "./App"; import { trash } from "./icons"; import { ToolButton } from "./ToolButton"; @@ -19,7 +19,7 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => { icon={trash} title={t("buttons.clearReset")} aria-label={t("buttons.clearReset")} - showAriaLabel={useDeviceType().isMobile} + showAriaLabel={useDevice().isMobile} onClick={toggleDialog} data-testid="clear-canvas-button" /> diff --git a/src/components/CollabButton.tsx b/src/components/CollabButton.tsx index 3e9c370cb4..d6544e95b3 100644 --- a/src/components/CollabButton.tsx +++ b/src/components/CollabButton.tsx @@ -1,7 +1,7 @@ import clsx from "clsx"; import { ToolButton } from "./ToolButton"; import { t } from "../i18n"; -import { useDeviceType } from "../components/App"; +import { useDevice } from "../components/App"; import { users } from "./icons"; import "./CollabButton.scss"; @@ -26,7 +26,7 @@ const CollabButton = ({ type="button" title={t("labels.liveCollaboration")} aria-label={t("labels.liveCollaboration")} - showAriaLabel={useDeviceType().isMobile} + showAriaLabel={useDevice().isMobile} > {collaboratorCount > 0 && (
{collaboratorCount}
diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index 6de3f00ba1..06615101ee 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -2,7 +2,7 @@ import clsx from "clsx"; import React, { useEffect, useState } from "react"; import { useCallbackRefState } from "../hooks/useCallbackRefState"; import { t } from "../i18n"; -import { useExcalidrawContainer, useDeviceType } from "../components/App"; +import { useExcalidrawContainer, useDevice } from "../components/App"; import { KEYS } from "../keys"; import "./Dialog.scss"; import { back, close } from "./icons"; @@ -94,7 +94,7 @@ export const Dialog = (props: DialogProps) => { onClick={onClose} aria-label={t("buttons.close")} > - {useDeviceType().isMobile ? back : close} + {useDevice().isMobile ? back : close}
{props.children}
diff --git a/src/components/ImageExportDialog.tsx b/src/components/ImageExportDialog.tsx index ca21362de0..bfc7f02c9c 100644 --- a/src/components/ImageExportDialog.tsx +++ b/src/components/ImageExportDialog.tsx @@ -5,7 +5,7 @@ import { canvasToBlob } from "../data/blob"; import { NonDeletedExcalidrawElement } from "../element/types"; import { CanvasError } from "../errors"; import { t } from "../i18n"; -import { useDeviceType } from "./App"; +import { useDevice } from "./App"; import { getSelectedElements, isSomeElementSelected } from "../scene"; import { exportToCanvas } from "../scene/export"; import { AppState, BinaryFiles } from "../types"; @@ -250,7 +250,7 @@ export const ImageExportDialog = ({ icon={exportImage} type="button" aria-label={t("buttons.exportImage")} - showAriaLabel={useDeviceType().isMobile} + showAriaLabel={useDevice().isMobile} title={t("buttons.exportImage")} /> {modalIsShown && ( diff --git a/src/components/JSONExportDialog.tsx b/src/components/JSONExportDialog.tsx index 5f29360d68..98e0519f37 100644 --- a/src/components/JSONExportDialog.tsx +++ b/src/components/JSONExportDialog.tsx @@ -1,7 +1,7 @@ import React, { useState } from "react"; import { NonDeletedExcalidrawElement } from "../element/types"; import { t } from "../i18n"; -import { useDeviceType } from "./App"; +import { useDevice } from "./App"; import { AppState, ExportOpts, BinaryFiles } from "../types"; import { Dialog } from "./Dialog"; import { exportFile, exportToFileIcon, link } from "./icons"; @@ -117,7 +117,7 @@ export const JSONExportDialog = ({ icon={exportFile} type="button" aria-label={t("buttons.export")} - showAriaLabel={useDeviceType().isMobile} + showAriaLabel={useDevice().isMobile} title={t("buttons.export")} /> {modalIsShown && ( diff --git a/src/components/LayerUI.scss b/src/components/LayerUI.scss index 5b1ce6d39e..92029ceebd 100644 --- a/src/components/LayerUI.scss +++ b/src/components/LayerUI.scss @@ -1,9 +1,63 @@ @import "open-color/open-color"; +@import "../css/variables.module"; + +.layer-ui__sidebar { + position: absolute; + top: var(--sat); + bottom: var(--sab); + right: var(--sar); + z-index: 5; + + box-shadow: var(--shadow-island); + overflow: hidden; + border-radius: var(--border-radius-lg); + margin: var(--space-factor); + width: calc(#{$right-sidebar-width} - var(--space-factor) * 2); + + .Island { + box-shadow: none; + } + + .ToolIcon__icon { + border-radius: var(--border-radius-md); + } + + .ToolIcon__icon__close { + .Modal__close { + width: calc(var(--space-factor) * 7); + height: calc(var(--space-factor) * 7); + display: flex; + justify-content: center; + align-items: center; + color: var(--color-text); + } + } + + .Island { + --padding: 0; + background-color: var(--island-bg-color); + border-radius: var(--border-radius-lg); + padding: calc(var(--padding) * var(--space-factor)); + position: relative; + transition: box-shadow 0.5s ease-in-out; + } +} .excalidraw { + .layer-ui__wrapper.animate { + transition: width 0.1s ease-in-out; + } .layer-ui__wrapper { + // when the rightside sidebar is docked, we need to resize the UI by its + // width, making the nested UI content shift to the left. To do this, + // we need the UI container to actually have dimensions set, but + // then we also need to disable pointer events else the canvas below + // wouldn't be interactive. + position: absolute; + width: 100%; + height: 100%; + pointer-events: none; z-index: var(--zIndex-layerUI); - &__top-right { display: flex; } diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index f8190a99f9..72a6b0e138 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -1,7 +1,7 @@ import clsx from "clsx"; import React, { useCallback } from "react"; import { ActionManager } from "../actions/manager"; -import { CLASSES } from "../constants"; +import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants"; import { exportCanvas } from "../data"; import { isTextElement, showSelectedShapeActions } from "../element"; import { NonDeletedExcalidrawElement } from "../element/types"; @@ -36,7 +36,9 @@ import "./LayerUI.scss"; import "./Toolbar.scss"; import { PenModeButton } from "./PenModeButton"; import { trackEvent } from "../analytics"; -import { useDeviceType } from "../components/App"; +import { useDevice } from "../components/App"; +import { Stats } from "./Stats"; +import { actionToggleStats } from "../actions/actionToggleStats"; interface LayerUIProps { actionManager: ActionManager; @@ -55,14 +57,9 @@ interface LayerUIProps { toggleZenMode: () => void; langCode: Language["code"]; isCollaborating: boolean; - renderTopRightUI?: ( - isMobile: boolean, - appState: AppState, - ) => JSX.Element | null; - renderCustomFooter?: ( - isMobile: boolean, - appState: AppState, - ) => JSX.Element | null; + renderTopRightUI?: ExcalidrawProps["renderTopRightUI"]; + renderCustomFooter?: ExcalidrawProps["renderFooter"]; + renderCustomStats?: ExcalidrawProps["renderCustomStats"]; viewModeEnabled: boolean; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; UIOptions: AppProps["UIOptions"]; @@ -71,7 +68,6 @@ interface LayerUIProps { id: string; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; } - const LayerUI = ({ actionManager, appState, @@ -90,6 +86,7 @@ const LayerUI = ({ isCollaborating, renderTopRightUI, renderCustomFooter, + renderCustomStats, viewModeEnabled, libraryReturnUrl, UIOptions, @@ -98,7 +95,7 @@ const LayerUI = ({ id, onImageAction, }: LayerUIProps) => { - const deviceType = useDeviceType(); + const device = useDevice(); const renderJSONExportDialog = () => { if (!UIOptions.canvasActions.export) { @@ -344,7 +341,7 @@ const LayerUI = ({ {heading} @@ -366,7 +363,6 @@ const LayerUI = ({ setAppState={setAppState} /> - {libraryMenu} )} @@ -383,7 +379,7 @@ const LayerUI = ({ collaborators={appState.collaborators} actionManager={actionManager} /> - {renderTopRightUI?.(deviceType.isMobile, appState)} + {renderTopRightUI?.(device.isMobile, appState)}
@@ -436,7 +432,7 @@ const LayerUI = ({ )} {!viewModeEnabled && appState.multiElement && - deviceType.isTouchScreen && ( + device.isTouchScreen && (
); - return deviceType.isMobile ? ( + const renderStats = () => { + if (!appState.showStats) { + return null; + } + return ( + { + actionManager.executeAction(actionToggleStats); + }} + renderCustomStats={renderCustomStats} + /> + ); + }; + + return device.isMobile ? ( <> {dialogs} ) : ( -
- {dialogs} - {renderFixedSideContainer()} - {renderBottomAppMenu()} - {appState.scrolledOutside && ( - + <> +
+ {dialogs} + {renderFixedSideContainer()} + {renderBottomAppMenu()} + {renderStats()} + {appState.scrolledOutside && ( + + )} +
+ {appState.isLibraryOpen && ( +
{libraryMenu}
)} -
+ ); }; diff --git a/src/components/LibraryButton.tsx b/src/components/LibraryButton.tsx index f6c398b2af..9b15d3e4c4 100644 --- a/src/components/LibraryButton.tsx +++ b/src/components/LibraryButton.tsx @@ -3,6 +3,8 @@ import clsx from "clsx"; import { t } from "../i18n"; import { AppState } from "../types"; import { capitalizeString } from "../utils"; +import { trackEvent } from "../analytics"; +import { useDevice } from "./App"; const LIBRARY_ICON = ( @@ -18,6 +20,7 @@ export const LibraryButton: React.FC<{ setAppState: React.Component["setState"]; isMobile?: boolean; }> = ({ appState, setAppState, isMobile }) => { + const device = useDevice(); return (
- {(!itemsSelected || !isMobile) && ( + {!itemsSelected && ( - {!isMobile && } + {!device.isMobile && } {selectedItems.length > 0 && ( {selectedItems.length} @@ -195,11 +198,25 @@ const LibraryMenuItems = ({ )} + {device.isMobile && ( + + )}
); }; - const CELLS_PER_ROW = isMobile ? 4 : 6; + const CELLS_PER_ROW = device.isMobile && !device.isSmScreen ? 6 : 4; const referrer = libraryReturnUrl || window.location.origin + window.location.pathname; @@ -356,48 +373,169 @@ const LibraryMenuItems = ({ (item) => item.status === "published", ); - return ( -
- {showRemoveLibAlert && renderRemoveLibAlert()} -
- {renderLibraryActions()} - {isLoading ? ( - - ) : ( - - {t("labels.libraries")} - - )} -
+ const renderLibraryHeader = () => { + return ( + <> +
+ {renderLibraryActions()} + {device.canDeviceFitSidebar && ( + <> +
+ { + document + .querySelector(".layer-ui__wrapper") + ?.classList.add("animate"); + const nextState = !appState.isLibraryMenuDocked; + setAppState({ + isLibraryMenuDocked: nextState, + }); + trackEvent( + "library", + `toggleLibraryDock (${nextState ? "dock" : "undock"})`, + `sidebar (${device.isMobile ? "mobile" : "desktop"})`, + ); + }} + /> +
+ + )} + {!device.isMobile && ( +
+ +
+ )} +
+ + ); + }; + + const renderLibraryMenuItems = () => { + return ( 0 ? 1 : "0 0 auto", + marginBottom: 0, + }} > <> -
{t("labels.personalLib")}
- {renderLibrarySection([ - // append pending library item - ...(pendingElements.length - ? [{ id: null, elements: pendingElements }] - : []), - ...unpublishedItems, - ])} +
+ {(pendingElements.length > 0 || + unpublishedItems.length > 0 || + publishedItems.length > 0) && ( +
{t("labels.personalLib")}
+ )} + {isLoading && ( +
+
+ +
+
+ )} +
+ {!pendingElements.length && !unpublishedItems.length ? ( +
+ No items yet! +
+ {publishedItems.length > 0 + ? t("library.hint_emptyPrivateLibrary") + : t("library.hint_emptyLibrary")} +
+
+ ) : ( + renderLibrarySection([ + // append pending library item + ...(pendingElements.length + ? [{ id: null, elements: pendingElements }] + : []), + ...unpublishedItems, + ]) + )} <> -
{t("labels.excalidrawLib")}
- - {renderLibrarySection(publishedItems)} + {(publishedItems.length > 0 || + (!device.isMobile && + (pendingElements.length > 0 || unpublishedItems.length > 0))) && ( +
{t("labels.excalidrawLib")}
+ )} + {publishedItems.length > 0 && renderLibrarySection(publishedItems)}
+ ); + }; + + const renderLibraryFooter = () => { + return ( + + {t("labels.libraries")} + + ); + }; + + return ( +
+ {showRemoveLibAlert && renderRemoveLibAlert()} + {renderLibraryHeader()} + {renderLibraryMenuItems()} + {!device.isMobile && renderLibraryFooter()}
); }; diff --git a/src/components/LibraryUnit.scss b/src/components/LibraryUnit.scss index dca3905095..e0c00814f5 100644 --- a/src/components/LibraryUnit.scss +++ b/src/components/LibraryUnit.scss @@ -3,7 +3,7 @@ .excalidraw { .library-unit { align-items: center; - border: 1px solid var(--button-gray-2); + border: 1px solid transparent; display: flex; justify-content: center; position: relative; @@ -21,10 +21,6 @@ } } - &.theme--dark .library-unit { - border-color: rgb(48, 48, 48); - } - .library-unit__dragger { display: flex; align-items: center; diff --git a/src/components/LibraryUnit.tsx b/src/components/LibraryUnit.tsx index 46f8d11be9..54bb6ff415 100644 --- a/src/components/LibraryUnit.tsx +++ b/src/components/LibraryUnit.tsx @@ -1,7 +1,7 @@ import clsx from "clsx"; import oc from "open-color"; import { useEffect, useRef, useState } from "react"; -import { useDeviceType } from "../components/App"; +import { useDevice } from "../components/App"; import { exportToSvg } from "../scene/export"; import { BinaryFiles, LibraryItem } from "../types"; import "./LibraryUnit.scss"; @@ -67,7 +67,7 @@ export const LibraryUnit = ({ }, [elements, files]); const [isHovered, setIsHovered] = useState(false); - const isMobile = useDeviceType().isMobile; + const isMobile = useDevice().isMobile; const adder = isPending && (
{PLUS_ICON}
); diff --git a/src/components/MobileMenu.tsx b/src/components/MobileMenu.tsx index e5771e5ab2..ea45b393ea 100644 --- a/src/components/MobileMenu.tsx +++ b/src/components/MobileMenu.tsx @@ -43,6 +43,7 @@ type MobileMenuProps = { isMobile: boolean, appState: AppState, ) => JSX.Element | null; + renderStats: () => JSX.Element | null; }; export const MobileMenu = ({ @@ -63,6 +64,7 @@ export const MobileMenu = ({ showThemeBtn, onImageAction, renderTopRightUI, + renderStats, }: MobileMenuProps) => { const renderToolbar = () => { return ( @@ -184,6 +186,7 @@ export const MobileMenu = ({ return ( <> {!viewModeEnabled && renderToolbar()} + {renderStats()}
{ const [div, setDiv] = useState(null); - const deviceType = useDeviceType(); - const isMobileRef = useRef(deviceType.isMobile); - isMobileRef.current = deviceType.isMobile; + const device = useDevice(); + const isMobileRef = useRef(device.isMobile); + isMobileRef.current = device.isMobile; const { container: excalidrawContainer } = useExcalidrawContainer(); useLayoutEffect(() => { if (div) { - div.classList.toggle("excalidraw--mobile", deviceType.isMobile); + div.classList.toggle("excalidraw--mobile", device.isMobile); } - }, [div, deviceType.isMobile]); + }, [div, device.isMobile]); useLayoutEffect(() => { const isDarkTheme = diff --git a/src/components/SidebarLockButton.scss b/src/components/SidebarLockButton.scss new file mode 100644 index 0000000000..0e6799a383 --- /dev/null +++ b/src/components/SidebarLockButton.scss @@ -0,0 +1,22 @@ +@import "../css/variables.module"; + +.excalidraw { + .layer-ui__sidebar-lock-button { + @include toolbarButtonColorStates; + margin-right: 0.2rem; + } + .ToolIcon_type_floating .side_lock_icon { + width: calc(var(--space-factor) * 7); + height: calc(var(--space-factor) * 7); + svg { + // mirror + transform: scale(-1, 1); + } + } + + .ToolIcon_type_checkbox { + &:not(.ToolIcon_toggle_opaque):checked + .side_lock_icon { + background-color: var(--color-primary); + } + } +} diff --git a/src/components/SidebarLockButton.tsx b/src/components/SidebarLockButton.tsx new file mode 100644 index 0000000000..2730c98254 --- /dev/null +++ b/src/components/SidebarLockButton.tsx @@ -0,0 +1,46 @@ +import "./ToolIcon.scss"; + +import React from "react"; +import clsx from "clsx"; +import { ToolButtonSize } from "./ToolButton"; +import { t } from "../i18n"; +import { Tooltip } from "./Tooltip"; + +import "./SidebarLockButton.scss"; + +type SidebarLockIconProps = { + checked: boolean; + onChange?(): void; +}; + +const DEFAULT_SIZE: ToolButtonSize = "medium"; + +const SIDE_LIBRARY_TOGGLE_ICON = ( + + + +); + +export const SidebarLockButton = (props: SidebarLockIconProps) => { + return ( + + {" "} + + ); +}; diff --git a/src/components/Stack.tsx b/src/components/Stack.tsx index cf937493f7..aa18e89985 100644 --- a/src/components/Stack.tsx +++ b/src/components/Stack.tsx @@ -41,6 +41,7 @@ const ColStack = ({ align, justifyContent, className, + style, }: StackProps) => { return (
{children} diff --git a/src/components/Stats.scss b/src/components/Stats.scss index 72acd26628..0a2f6b62dc 100644 --- a/src/components/Stats.scss +++ b/src/components/Stats.scss @@ -7,6 +7,7 @@ right: 12px; font-size: 12px; z-index: 10; + pointer-events: all; h3 { margin: 0 24px 8px 0; diff --git a/src/components/Stats.tsx b/src/components/Stats.tsx index 61b2df098c..b3f2816c9e 100644 --- a/src/components/Stats.tsx +++ b/src/components/Stats.tsx @@ -2,7 +2,7 @@ import React from "react"; import { getCommonBounds } from "../element/bounds"; import { NonDeletedExcalidrawElement } from "../element/types"; import { t } from "../i18n"; -import { useDeviceType } from "../components/App"; +import { useDevice } from "../components/App"; import { getTargetElements } from "../scene"; import { AppState, ExcalidrawProps } from "../types"; import { close } from "./icons"; @@ -16,16 +16,13 @@ export const Stats = (props: { onClose: () => void; renderCustomStats: ExcalidrawProps["renderCustomStats"]; }) => { - const deviceType = useDeviceType(); - + const device = useDevice(); const boundingBox = getCommonBounds(props.elements); const selectedElements = getTargetElements(props.elements, props.appState); const selectedBoundingBox = getCommonBounds(selectedElements); - - if (deviceType.isMobile && props.appState.openMenu) { + if (device.isMobile && props.appState.openMenu) { return null; } - return (
diff --git a/src/components/Toolbar.scss b/src/components/Toolbar.scss index fb2a32b175..e6831b45b0 100644 --- a/src/components/Toolbar.scss +++ b/src/components/Toolbar.scss @@ -1,26 +1,5 @@ @import "open-color/open-color.scss"; - -@mixin toolbarButtonColorStates { - .ToolIcon_type_radio, - .ToolIcon_type_checkbox { - & + .ToolIcon__icon:active { - background: var(--color-primary-light); - } - &:checked + .ToolIcon__icon { - background: var(--color-primary); - --icon-fill-color: #{$oc-white}; - --keybinding-color: #{$oc-white}; - } - &:checked + .ToolIcon__icon:active { - background: var(--color-primary-darker); - } - } - - .ToolIcon__keybinding { - bottom: 4px; - right: 4px; - } -} +@import "../css/variables.module"; .excalidraw { .App-toolbar-container { diff --git a/src/constants.ts b/src/constants.ts index afb7ecf3e3..9bbacd8f7f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -155,9 +155,19 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = { }, }; +// breakpoints +// ----------------------------------------------------------------------------- +// sm screen +export const MQ_SM_MAX_WIDTH = 640; +// md screen export const MQ_MAX_WIDTH_PORTRAIT = 730; export const MQ_MAX_WIDTH_LANDSCAPE = 1000; export const MQ_MAX_HEIGHT_LANDSCAPE = 500; +// sidebar +export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229; +// ----------------------------------------------------------------------------- + +export const LIBRARY_SIDEBAR_WIDTH = parseInt(cssVariables.rightSidebarWidth); export const MAX_DECIMALS_FOR_SVG_EXPORT = 2; diff --git a/src/css/styles.scss b/src/css/styles.scss index 0de93901bd..920f7e7aea 100644 --- a/src/css/styles.scss +++ b/src/css/styles.scss @@ -350,7 +350,6 @@ align-items: flex-start; cursor: default; pointer-events: none !important; - z-index: 100; :root[dir="ltr"] & { left: 0.25rem; @@ -391,6 +390,7 @@ .App-menu__left { overflow-y: auto; + box-shadow: var(--shadow-island); } .dropdown-select { @@ -449,6 +449,7 @@ bottom: 30px; transform: translateX(-50%); padding: 10px 20px; + pointer-events: all; } .help-icon { @@ -567,6 +568,22 @@ display: none; } } + + // use custom, minimalistic scrollbar + // (doesn't work in Firefox) + ::-webkit-scrollbar { + width: 5px; + } + ::-webkit-scrollbar-thumb { + background: var(--button-gray-2); + border-radius: 10px; + } + ::-webkit-scrollbar-thumb:hover { + background: var(--button-gray-3); + } + ::-webkit-scrollbar-thumb:active { + background: var(--button-gray-2); + } } .ErrorSplash.excalidraw { diff --git a/src/css/variables.module.scss b/src/css/variables.module.scss index 0d2c37f99a..4c90fd1360 100644 --- a/src/css/variables.module.scss +++ b/src/css/variables.module.scss @@ -6,8 +6,32 @@ } } +@mixin toolbarButtonColorStates { + .ToolIcon_type_radio, + .ToolIcon_type_checkbox { + & + .ToolIcon__icon:active { + background: var(--color-primary-light); + } + &:checked + .ToolIcon__icon { + background: var(--color-primary); + --icon-fill-color: #{$oc-white}; + --keybinding-color: #{$oc-white}; + } + &:checked + .ToolIcon__icon:active { + background: var(--color-primary-darker); + } + } + + .ToolIcon__keybinding { + bottom: 4px; + right: 4px; + } +} + $theme-filter: "invert(93%) hue-rotate(180deg)"; +$right-sidebar-width: "302px"; :export { themeFilter: unquote($theme-filter); + rightSidebarWidth: unquote($right-sidebar-width); } diff --git a/src/data/restore.ts b/src/data/restore.ts index 8b5ba43366..24bfca79f7 100644 --- a/src/data/restore.ts +++ b/src/data/restore.ts @@ -283,6 +283,11 @@ export const restoreAppState = ( value: appState.zoom as NormalizedZoomValue, } : appState.zoom || defaultAppState.zoom, + // when sidebar docked and user left it open in last session, + // keep it open. If not docked, keep it closed irrespective of last state. + isLibraryOpen: nextAppState.isLibraryMenuDocked + ? nextAppState.isLibraryOpen + : false, }; }; diff --git a/src/locales/en.json b/src/locales/en.json index f6d887cf55..b20f6a4e16 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -120,7 +120,12 @@ "lockAll": "Lock all", "unlockAll": "Unlock all" }, - "statusPublished": "Published" + "statusPublished": "Published", + "sidebarLock": "Keep sidebar open" + }, + "library": { + "hint_emptyLibrary": "Select an item on canvas to add it here, or install a library from the public repository, below.", + "hint_emptyPrivateLibrary": "Select an item on canvas to add it here." }, "buttons": { "clearReset": "Reset the canvas", diff --git a/src/packages/excalidraw/CHANGELOG.md b/src/packages/excalidraw/CHANGELOG.md index 8f5265dbbc..c5030800dc 100644 --- a/src/packages/excalidraw/CHANGELOG.md +++ b/src/packages/excalidraw/CHANGELOG.md @@ -17,6 +17,8 @@ Please add the latest change on the top under the correct section. #### Features +- Add `[UIOptions.dockedSidebarBreakpoint]`(https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#dockedSidebarBreakpoint) to customize at which point to break from the docked sidebar [#5274](https://github.com/excalidraw/excalidraw/pull/5274). + - Added support for supplying user `id` in the Collaborator object (see `collaborators` in [`updateScene()`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#updateScene)), which will be used to deduplicate users when rendering collaborator avatar list. Cursors will still be rendered for every user. [#5309](https://github.com/excalidraw/excalidraw/pull/5309) - Export API to [set](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#setCursor) and [reset](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#resetCursor) mouse cursor on the canvas [#5215](https://github.com/excalidraw/excalidraw/pull/5215). diff --git a/src/packages/excalidraw/README_NEXT.md b/src/packages/excalidraw/README_NEXT.md index 1cb7e617da..d01d128033 100644 --- a/src/packages/excalidraw/README_NEXT.md +++ b/src/packages/excalidraw/README_NEXT.md @@ -639,7 +639,7 @@ This prop sets the name of the drawing which will be used when exporting the dra #### `UIOptions` -This prop can be used to customise UI of Excalidraw. Currently we support customising only [`canvasActions`](#canvasActions). It accepts the below parameters +This prop can be used to customise UI of Excalidraw. Currently we support customising [`canvasActions`](#canvasActions) and [`dockedSidebarBreakpoint`](dockedSidebarBreakpoint). It accepts the below parameters
 { canvasActions:  CanvasActions }
@@ -657,6 +657,12 @@ This prop can be used to customise UI of Excalidraw. Currently we support custom
 | `theme` | boolean | true | Implies whether to show `Theme toggle` |
 | `saveAsImage` | boolean | true | Implies whether to show `Save as image button` |
 
+##### `dockedSidebarBreakpoint`
+
+This prop indicates at what point should we break to a docked, permanent sidebar. If not passed it defaults to [`MQ_RIGHT_SIDEBAR_MAX_WIDTH_PORTRAIT`](https://github.com/excalidraw/excalidraw/blob/master/src/constants.ts#L167). If the `width` of the `excalidraw` container exceeds `dockedSidebarBreakpoint`, the sidebar will be dockable. If user choses to dock the sidebar, it will push the right part of the UI towards the left, making space for the sidebar as shown below.
+
+![image](https://user-images.githubusercontent.com/11256141/174664866-c698c3fa-197b-43ff-956c-d79852c7b326.png)
+
 #### `exportOpts`
 
 The below attributes can be set in `UIOptions.canvasActions.export` to customize the export dialog. If `UIOptions.canvasActions.export` is `false` the export button will not be rendered.
diff --git a/src/packages/excalidraw/index.tsx b/src/packages/excalidraw/index.tsx
index 936effdb26..fc567a5cb2 100644
--- a/src/packages/excalidraw/index.tsx
+++ b/src/packages/excalidraw/index.tsx
@@ -44,6 +44,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
   const canvasActions = props.UIOptions?.canvasActions;
 
   const UIOptions: AppProps["UIOptions"] = {
+    ...props.UIOptions,
     canvasActions: {
       ...DEFAULT_UI_OPTIONS.canvasActions,
       ...canvasActions,
diff --git a/src/tests/__snapshots__/contextmenu.test.tsx.snap b/src/tests/__snapshots__/contextmenu.test.tsx.snap
index 156a17f49a..72c4defdfe 100644
--- a/src/tests/__snapshots__/contextmenu.test.tsx.snap
+++ b/src/tests/__snapshots__/contextmenu.test.tsx.snap
@@ -38,6 +38,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -211,6 +212,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -388,6 +390,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -726,6 +729,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -1064,6 +1068,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -1241,6 +1246,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -1454,6 +1460,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -1726,6 +1733,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -2082,6 +2090,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -2882,6 +2891,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -3220,6 +3230,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -3558,6 +3569,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -3976,6 +3988,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -4254,6 +4267,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -4613,6 +4627,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -4719,6 +4734,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -4803,6 +4819,7 @@ Object {
   "gridSize": null,
   "height": 100,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
diff --git a/src/tests/__snapshots__/regressionTests.test.tsx.snap b/src/tests/__snapshots__/regressionTests.test.tsx.snap
index 14270d1dbb..9716320cbb 100644
--- a/src/tests/__snapshots__/regressionTests.test.tsx.snap
+++ b/src/tests/__snapshots__/regressionTests.test.tsx.snap
@@ -38,6 +38,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -547,6 +548,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -1062,6 +1064,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": false,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -1922,6 +1925,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -2143,6 +2147,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -2649,6 +2654,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -2925,6 +2931,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -3102,6 +3109,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -3591,6 +3599,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -3848,6 +3857,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -4069,6 +4079,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -4334,6 +4345,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -4609,6 +4621,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -5031,6 +5044,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -5355,6 +5369,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -5654,6 +5669,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -5881,6 +5897,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -6058,6 +6075,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -6559,6 +6577,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -6907,6 +6926,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -9146,6 +9166,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -9544,6 +9565,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -9820,6 +9842,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -10057,6 +10080,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -10363,6 +10387,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -10540,6 +10565,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -10717,6 +10743,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -10894,6 +10921,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -11101,6 +11129,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -11308,6 +11337,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -11533,6 +11563,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -11740,6 +11771,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -11917,6 +11949,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -12124,6 +12157,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -12301,6 +12335,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -12478,6 +12513,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -12703,6 +12739,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -13497,6 +13534,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -13773,6 +13811,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -13881,6 +13920,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -13987,6 +14027,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -14167,6 +14208,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -14518,6 +14560,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -14735,6 +14778,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -15647,6 +15691,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -15753,6 +15798,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -16592,6 +16638,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -17039,6 +17086,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -17338,6 +17386,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -17446,6 +17495,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -17990,6 +18040,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
@@ -18096,6 +18147,7 @@ Object {
   "gridSize": null,
   "height": 768,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
diff --git a/src/tests/packages/__snapshots__/utils.test.ts.snap b/src/tests/packages/__snapshots__/utils.test.ts.snap
index 32f519c48a..ebc6b9b241 100644
--- a/src/tests/packages/__snapshots__/utils.test.ts.snap
+++ b/src/tests/packages/__snapshots__/utils.test.ts.snap
@@ -38,6 +38,7 @@ Object {
   "fileHandle": null,
   "gridSize": null,
   "isBindingEnabled": true,
+  "isLibraryMenuDocked": false,
   "isLibraryOpen": false,
   "isLoading": false,
   "isResizing": false,
diff --git a/src/types.ts b/src/types.ts
index eb7e534c01..21e4b164d9 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -162,6 +162,7 @@ export type AppState = {
   offsetLeft: number;
 
   isLibraryOpen: boolean;
+  isLibraryMenuDocked: boolean;
   fileHandle: FileSystemHandle | null;
   collaborators: Map;
   showStats: boolean;
@@ -291,7 +292,10 @@ export interface ExcalidrawProps {
     elements: readonly NonDeletedExcalidrawElement[],
     appState: AppState,
   ) => JSX.Element;
-  UIOptions?: UIOptions;
+  UIOptions?: {
+    dockedSidebarBreakpoint?: number;
+    canvasActions?: CanvasActions;
+  };
   detectScroll?: boolean;
   handleKeyboardGlobally?: boolean;
   onLibraryChange?: (libraryItems: LibraryItems) => void | Promise;
@@ -349,18 +353,18 @@ type CanvasActions = {
   saveAsImage?: boolean;
 };
 
-export type UIOptions = {
-  canvasActions?: CanvasActions;
-};
-
-export type AppProps = ExcalidrawProps & {
-  UIOptions: {
-    canvasActions: Required & { export: ExportOpts };
-  };
-  detectScroll: boolean;
-  handleKeyboardGlobally: boolean;
-  isCollaborating: boolean;
-};
+export type AppProps = Merge<
+  ExcalidrawProps,
+  {
+    UIOptions: {
+      canvasActions: Required & { export: ExportOpts };
+      dockedSidebarBreakpoint?: number;
+    };
+    detectScroll: boolean;
+    handleKeyboardGlobally: boolean;
+    isCollaborating: boolean;
+  }
+>;
 
 /** A subset of App class properties that we need to use elsewhere
  * in the app, eg Manager. Factored out into a separate type to keep DRY. */
@@ -377,7 +381,7 @@ export type AppClassProperties = {
     }
   >;
   files: BinaryFiles;
-  deviceType: App["deviceType"];
+  device: App["device"];
   scene: App["scene"];
 };
 
@@ -473,7 +477,9 @@ export type ExcalidrawImperativeAPI = {
   resetCursor: InstanceType["resetCursor"];
 };
 
-export type DeviceType = {
+export type Device = Readonly<{
+  isSmScreen: boolean;
   isMobile: boolean;
   isTouchScreen: boolean;
-};
+  canDeviceFitSidebar: boolean;
+}>;