diff --git a/src/actions/actionExport.tsx b/src/actions/actionExport.tsx index dda8569c..7ae69db9 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 7481d56a..246bfe7a 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 c320ffc1..879d0590 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 29eddbe0..897bc09a 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 4c36f602..4a861f08 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 6d25a4a1..ab1cd670 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 3e9c370c..d6544e95 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 6de3f00b..06615101 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 ca21362d..bfc7f02c 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 5f29360d..98e0519f 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 5b1ce6d3..92029cee 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 f8190a99..72a6b0e1 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 f6c398b2..9b15d3e4 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 dca39050..e0c00814 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 46f8d11b..54bb6ff4 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 e5771e5a..ea45b393 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 00000000..0e6799a3 --- /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 00000000..2730c982 --- /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 cf937493..aa18e899 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 72acd266..0a2f6b62 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 61b2df09..b3f2816c 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 fb2a32b1..e6831b45 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 afb7ecf3..9bbacd8f 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 0de93901..920f7e7a 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 0d2c37f9..4c90fd13 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 8b5ba433..24bfca79 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 f6d887cf..b20f6a4e 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 8f5265db..c5030800 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 1cb7e617..d01d1280 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 936effdb..fc567a5c 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 156a17f4..72c4defd 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 14270d1d..9716320c 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 32f519c4..ebc6b9b2 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 eb7e534c..21e4b164 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;
+}>;