From e9067de173712a295d1c2e8c8f6940dd1029dbc2 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Mon, 17 Oct 2022 12:25:24 +0200 Subject: [PATCH] feat: refactor Sidebar into standalone reusable component (#5663) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🚀! --- src/appState.ts | 8 +- src/components/App.tsx | 181 +++++---- src/components/HintViewer.tsx | 14 +- src/components/LayerUI.scss | 42 --- src/components/LayerUI.tsx | 79 ++-- src/components/LibraryButton.tsx | 8 +- src/components/LibraryMenu.tsx | 13 +- src/components/LibraryMenuItems.scss | 2 - src/components/LibraryMenuItems.tsx | 60 +-- src/components/MobileMenu.tsx | 18 +- src/components/Sidebar/Sidebar.scss | 89 +++++ src/components/Sidebar/Sidebar.test.tsx | 355 ++++++++++++++++++ src/components/Sidebar/Sidebar.tsx | 121 ++++++ src/components/Sidebar/SidebarHeader.tsx | 95 +++++ src/components/Sidebar/common.ts | 22 ++ src/components/SidebarLockButton.scss | 22 -- src/components/SidebarLockButton.tsx | 46 --- src/components/hoc/withUpstreamOverride.tsx | 63 ++++ src/data/library.ts | 2 +- src/data/restore.ts | 67 +++- src/data/types.ts | 22 +- src/packages/excalidraw/CHANGELOG.md | 2 + src/packages/excalidraw/README.md | 68 +++- src/packages/excalidraw/example/App.tsx | 91 +++-- .../{Sidebar.scss => ExampleSidebar.scss} | 0 .../{Sidebar.tsx => ExampleSidebar.tsx} | 2 +- src/packages/excalidraw/index.tsx | 4 + .../__snapshots__/contextmenu.test.tsx.snap | 68 ++-- .../regressionTests.test.tsx.snap | 208 +++++----- .../packages/__snapshots__/utils.test.ts.snap | 4 +- src/tests/test-utils.ts | 46 ++- src/types.ts | 11 +- 32 files changed, 1369 insertions(+), 464 deletions(-) create mode 100644 src/components/Sidebar/Sidebar.scss create mode 100644 src/components/Sidebar/Sidebar.test.tsx create mode 100644 src/components/Sidebar/Sidebar.tsx create mode 100644 src/components/Sidebar/SidebarHeader.tsx create mode 100644 src/components/Sidebar/common.ts delete mode 100644 src/components/SidebarLockButton.scss delete mode 100644 src/components/SidebarLockButton.tsx create mode 100644 src/components/hoc/withUpstreamOverride.tsx rename src/packages/excalidraw/example/sidebar/{Sidebar.scss => ExampleSidebar.scss} (100%) rename src/packages/excalidraw/example/sidebar/{Sidebar.tsx => ExampleSidebar.tsx} (96%) diff --git a/src/appState.ts b/src/appState.ts index 8754209b..ab501186 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -57,8 +57,7 @@ export const getDefaultAppState = (): Omit< fileHandle: null, gridSize: null, isBindingEnabled: true, - isLibraryOpen: false, - isLibraryMenuDocked: false, + isSidebarDocked: false, isLoading: false, isResizing: false, isRotating: false, @@ -67,6 +66,7 @@ export const getDefaultAppState = (): Omit< name: `${t("labels.untitled")}-${getDateTime()}`, openMenu: null, openPopup: null, + openSidebar: null, pasteDialog: { shown: false, data: null }, previousSelectedElementIds: {}, resizingElement: null, @@ -148,8 +148,7 @@ 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: true, export: false, server: false }, - isLibraryMenuDocked: { browser: true, export: false, server: false }, + isSidebarDocked: { 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 }, @@ -160,6 +159,7 @@ const APP_STATE_STORAGE_CONF = (< offsetTop: { browser: false, export: false, server: false }, openMenu: { browser: true, export: false, server: false }, openPopup: { browser: false, export: false, server: false }, + openSidebar: { browser: true, export: false, server: false }, pasteDialog: { browser: false, export: false, server: false }, previousSelectedElementIds: { browser: true, export: false, server: false }, resizingElement: { browser: false, export: false, server: false }, diff --git a/src/components/App.tsx b/src/components/App.tsx index 739d0ae5..a357a9b0 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -293,10 +293,17 @@ const ExcalidrawAppStateContext = React.createContext({ offsetLeft: 0, offsetTop: 0, }); + +const ExcalidrawSetAppStateContent = React.createContext< + React.Component["setState"] +>(() => {}); + export const useExcalidrawElements = () => useContext(ExcalidrawElementsContext); export const useExcalidrawAppState = () => useContext(ExcalidrawAppStateContext); +export const useExcalidrawSetAppState = () => + useContext(ExcalidrawSetAppStateContent); let didTapTwice: boolean = false; let tappedTwiceTimer = 0; @@ -380,7 +387,7 @@ class App extends React.Component { width: window.innerWidth, height: window.innerHeight, showHyperlinkPopup: false, - isLibraryMenuDocked: false, + isSidebarDocked: false, }; this.id = nanoid(); @@ -412,6 +419,7 @@ class App extends React.Component { setActiveTool: this.setActiveTool, setCursor: this.setCursor, resetCursor: this.resetCursor, + toggleMenu: this.toggleMenu, } as const; if (typeof excalidrawRef === "function") { excalidrawRef(api); @@ -524,65 +532,68 @@ class App extends React.Component { value={this.excalidrawContainerValue} > - - - - this.addElementsFromPasteOrLibrary({ - elements, - position: "center", - files: null, - }) - } - langCode={getLanguage().code} - isCollaborating={this.props.isCollaborating} - renderTopRightUI={renderTopRightUI} - renderCustomFooter={renderFooter} - renderCustomStats={renderCustomStats} - showExitZenModeBtn={ - typeof this.props?.zenModeEnabled === "undefined" && - this.state.zenModeEnabled - } - libraryReturnUrl={this.props.libraryReturnUrl} - UIOptions={this.props.UIOptions} - focusContainer={this.focusContainer} - library={this.library} - id={this.id} - onImageAction={this.onImageAction} - /> -
-
- {selectedElement.length === 1 && - this.state.showHyperlinkPopup && ( - + + + + this.addElementsFromPasteOrLibrary({ + elements, + position: "center", + files: null, + }) + } + langCode={getLanguage().code} + isCollaborating={this.props.isCollaborating} + renderTopRightUI={renderTopRightUI} + renderCustomFooter={renderFooter} + renderCustomStats={renderCustomStats} + renderCustomSidebar={this.props.renderSidebar} + showExitZenModeBtn={ + typeof this.props?.zenModeEnabled === "undefined" && + this.state.zenModeEnabled + } + libraryReturnUrl={this.props.libraryReturnUrl} + UIOptions={this.props.UIOptions} + focusContainer={this.focusContainer} + library={this.library} + id={this.id} + onImageAction={this.onImageAction} + /> +
+
+ {selectedElement.length === 1 && + this.state.showHyperlinkPopup && ( + + )} + {this.state.toast !== null && ( + this.setToast(null)} + duration={this.state.toast.duration} + closable={this.state.toast.closable} /> )} - {this.state.toast !== null && ( - this.setToast(null)} - duration={this.state.toast.duration} - closable={this.state.toast.closable} - /> - )} -
{this.renderCanvas()}
- {" "} - +
{this.renderCanvas()}
+ {" "} + +
@@ -787,8 +798,7 @@ class App extends React.Component { // 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, + openSidebar: scene.appState?.openSidebar || this.state.openSidebar, activeTool: scene.appState.activeTool.type === "image" ? { ...scene.appState.activeTool, type: "selection" } @@ -1562,10 +1572,17 @@ class App extends React.Component { selectGroupsForSelectedElements( { ...this.state, - isLibraryOpen: - this.state.isLibraryOpen && this.device.canDeviceFitSidebar - ? this.state.isLibraryMenuDocked - : false, + // keep sidebar (presumably the library) open if it's docked and + // can fit. + // + // Note, we should close the sidebar only if we're dropping items + // from library, not when pasting from clipboard. Alas. + openSidebar: + this.state.openSidebar && + this.device.canDeviceFitSidebar && + this.state.isSidebarDocked + ? this.state.openSidebar + : null, selectedElementIds: newElements.reduce( (acc: Record, element) => { if (!isBoundToContainer(element)) { @@ -1623,8 +1640,8 @@ class App extends React.Component { // Collaboration - setAppState = (obj: any) => { - this.setState(obj); + setAppState: React.Component["setState"] = (state) => { + this.setState(state); }; removePointer = (event: React.PointerEvent | PointerEvent) => { @@ -1762,6 +1779,35 @@ class App extends React.Component { this.setState({}); }; + /** + * @returns whether the menu was toggled on or off + */ + public toggleMenu = ( + type: "library" | "customSidebar", + force?: boolean, + ): boolean => { + if (type === "customSidebar" && !this.props.renderSidebar) { + console.warn( + `attempting to toggle "customSidebar", but no "props.renderSidebar" is defined`, + ); + return false; + } + + if (type === "library" || type === "customSidebar") { + let nextValue; + if (force === undefined) { + nextValue = this.state.openSidebar === type ? null : type; + } else { + nextValue = force ? type : null; + } + this.setState({ openSidebar: nextValue }); + + return !!nextValue; + } + + return false; + }; + private updateCurrentCursorPosition = withBatchedUpdates( (event: MouseEvent) => { cursorX = event.clientX; @@ -1837,8 +1883,7 @@ class App extends React.Component { } if (event.code === CODES.ZERO) { - const nextState = !this.state.isLibraryOpen; - this.setState({ isLibraryOpen: nextState }); + const nextState = this.toggleMenu("library"); // track only openings if (nextState) { trackEvent( diff --git a/src/components/HintViewer.tsx b/src/components/HintViewer.tsx index ecce71b5..6615c91e 100644 --- a/src/components/HintViewer.tsx +++ b/src/components/HintViewer.tsx @@ -3,7 +3,7 @@ import { NonDeletedExcalidrawElement } from "../element/types"; import { getSelectedElements } from "../scene"; import "./HintViewer.scss"; -import { AppState } from "../types"; +import { AppState, Device } from "../types"; import { isImageElement, isLinearElement, @@ -17,13 +17,19 @@ interface HintViewerProps { appState: AppState; elements: readonly NonDeletedExcalidrawElement[]; isMobile: boolean; + device: Device; } -const getHints = ({ appState, elements, isMobile }: HintViewerProps) => { +const getHints = ({ + appState, + elements, + isMobile, + device, +}: HintViewerProps) => { const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState; const multiMode = appState.multiElement !== null; - if (appState.isLibraryOpen) { + if (appState.openSidebar === "library" && !device.canDeviceFitSidebar) { return null; } @@ -111,11 +117,13 @@ export const HintViewer = ({ appState, elements, isMobile, + device, }: HintViewerProps) => { let hint = getHints({ appState, elements, isMobile, + device, }); if (!hint) { return null; diff --git a/src/components/LayerUI.scss b/src/components/LayerUI.scss index 92029cee..19223390 100644 --- a/src/components/LayerUI.scss +++ b/src/components/LayerUI.scss @@ -1,48 +1,6 @@ @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; diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 69155821..62eb3656 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -40,6 +40,9 @@ import { useDevice } from "../components/App"; import { Stats } from "./Stats"; import { actionToggleStats } from "../actions/actionToggleStats"; import Footer from "./Footer"; +import { hostSidebarCountersAtom, Sidebar } from "./Sidebar/Sidebar"; +import { jotaiScope } from "../jotai"; +import { useAtom } from "jotai"; interface LayerUIProps { actionManager: ActionManager; @@ -58,6 +61,7 @@ interface LayerUIProps { renderTopRightUI?: ExcalidrawProps["renderTopRightUI"]; renderCustomFooter?: ExcalidrawProps["renderFooter"]; renderCustomStats?: ExcalidrawProps["renderCustomStats"]; + renderCustomSidebar?: ExcalidrawProps["renderSidebar"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; UIOptions: AppProps["UIOptions"]; focusContainer: () => void; @@ -81,6 +85,7 @@ const LayerUI = ({ renderTopRightUI, renderCustomFooter, renderCustomStats, + renderCustomSidebar, libraryReturnUrl, UIOptions, focusContainer, @@ -249,7 +254,7 @@ const LayerUI = ({ if (isDialogOpen) { return; } - setAppState({ isLibraryOpen: false }); + setAppState({ openSidebar: null }); }, [setAppState]); const deselectItems = useCallback(() => { @@ -259,23 +264,24 @@ const LayerUI = ({ }); }, [setAppState]); - const libraryMenu = appState.isLibraryOpen ? ( - { - onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems)); - }} - onAddToLibrary={deselectItems} - setAppState={setAppState} - libraryReturnUrl={libraryReturnUrl} - focusContainer={focusContainer} - library={library} - files={files} - id={id} - appState={appState} - /> - ) : null; + const libraryMenu = + appState.openSidebar === "library" ? ( + { + onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems)); + }} + onAddToLibrary={deselectItems} + setAppState={setAppState} + libraryReturnUrl={libraryReturnUrl} + focusContainer={focusContainer} + library={library} + files={files} + id={id} + appState={appState} + /> + ) : null; const renderFixedSideContainer = () => { const shouldRenderSelectedShapeActions = showSelectedShapeActions( @@ -330,6 +336,7 @@ const LayerUI = ({ appState={appState} elements={elements} isMobile={device.isMobile} + device={device} /> {heading} @@ -374,6 +381,8 @@ const LayerUI = ({ ); }; + const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope); + return ( <> {appState.isLoading && } @@ -420,6 +429,8 @@ const LayerUI = ({ onImageAction={onImageAction} renderTopRightUI={renderTopRightUI} renderCustomStats={renderCustomStats} + renderCustomSidebar={renderCustomSidebar} + device={device} /> )} @@ -434,8 +445,9 @@ const LayerUI = ({ !isTextElement(appState.editingElement)), })} style={ - appState.isLibraryOpen && - appState.isLibraryMenuDocked && + ((appState.openSidebar === "library" && + appState.isSidebarDocked) || + hostSidebarCounters.docked) && device.canDeviceFitSidebar ? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` } : {} @@ -472,9 +484,26 @@ const LayerUI = ({ )}
- {appState.isLibraryOpen && ( -
{libraryMenu}
- )} + {appState.openSidebar === "customSidebar" ? ( + renderCustomSidebar?.() + ) : appState.openSidebar === "library" ? ( + { + trackEvent( + "library", + `toggleLibraryDock (${docked ? "dock" : "undock"})`, + `sidebar (${device.isMobile ? "mobile" : "desktop"})`, + ); + }} + > + {libraryMenu} + + ) : null} )} @@ -494,8 +523,12 @@ const areEqual = (prev: LayerUIProps, next: LayerUIProps) => { const nextAppState = getNecessaryObj(next.appState); const keys = Object.keys(prevAppState) as (keyof Partial)[]; + return ( prev.renderCustomFooter === next.renderCustomFooter && + prev.renderTopRightUI === next.renderTopRightUI && + prev.renderCustomStats === next.renderCustomStats && + prev.renderCustomSidebar === next.renderCustomSidebar && prev.langCode === next.langCode && prev.elements === next.elements && prev.files === next.files && diff --git a/src/components/LibraryButton.tsx b/src/components/LibraryButton.tsx index 9b15d3e4..98fcabf6 100644 --- a/src/components/LibraryButton.tsx +++ b/src/components/LibraryButton.tsx @@ -40,10 +40,10 @@ export const LibraryButton: React.FC<{ document .querySelector(".layer-ui__wrapper") ?.classList.remove("animate"); - const nextState = event.target.checked; - setAppState({ isLibraryOpen: nextState }); + const isOpen = event.target.checked; + setAppState({ openSidebar: isOpen ? "library" : null }); // track only openings - if (nextState) { + if (isOpen) { trackEvent( "library", "toggleLibrary (open)", @@ -51,7 +51,7 @@ export const LibraryButton: React.FC<{ ); } }} - checked={appState.isLibraryOpen} + checked={appState.openSidebar === "library"} aria-label={capitalizeString(t("toolBar.library"))} aria-keyshortcuts="0" /> diff --git a/src/components/LibraryMenu.tsx b/src/components/LibraryMenu.tsx index ebd60976..63dd587d 100644 --- a/src/components/LibraryMenu.tsx +++ b/src/components/LibraryMenu.tsx @@ -17,7 +17,6 @@ import { ExcalidrawProps, } from "../types"; import { Dialog } from "./Dialog"; -import { Island } from "./Island"; import PublishLibrary from "./PublishLibrary"; import { ToolButton } from "./ToolButton"; @@ -69,9 +68,9 @@ const LibraryMenuWrapper = forwardRef< { children: React.ReactNode } >(({ children }, ref) => { return ( - +
{children} - +
); }); @@ -112,11 +111,11 @@ export const LibraryMenu = ({ if ((event.target as Element).closest(".ToolIcon__library")) { return; } - if (!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar) { + if (!appState.isSidebarDocked || !device.canDeviceFitSidebar) { onClose(); } }, - [onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar], + [onClose, appState.isSidebarDocked, device.canDeviceFitSidebar], ), ); @@ -124,7 +123,7 @@ export const LibraryMenu = ({ const handleKeyDown = (event: KeyboardEvent) => { if ( event.key === KEYS.ESCAPE && - (!appState.isLibraryMenuDocked || !device.canDeviceFitSidebar) + (!appState.isSidebarDocked || !device.canDeviceFitSidebar) ) { onClose(); } @@ -133,7 +132,7 @@ export const LibraryMenu = ({ return () => { document.removeEventListener(EVENT.KEYDOWN, handleKeyDown); }; - }, [onClose, appState.isLibraryMenuDocked, device.canDeviceFitSidebar]); + }, [onClose, appState.isSidebarDocked, device.canDeviceFitSidebar]); const [selectedItems, setSelectedItems] = useState([]); const [showPublishLibraryDialog, setShowPublishLibraryDialog] = diff --git a/src/components/LibraryMenuItems.scss b/src/components/LibraryMenuItems.scss index 457181d5..466bcc34 100644 --- a/src/components/LibraryMenuItems.scss +++ b/src/components/LibraryMenuItems.scss @@ -5,8 +5,6 @@ display: flex; flex-direction: column; height: 100%; - padding: 0.5rem; - box-sizing: border-box; .library-actions { width: 100%; diff --git a/src/components/LibraryMenuItems.tsx b/src/components/LibraryMenuItems.tsx index b39003cd..9110a918 100644 --- a/src/components/LibraryMenuItems.tsx +++ b/src/components/LibraryMenuItems.tsx @@ -13,7 +13,7 @@ import { import { arrayToMap, chunk, muteFSAbortError } from "../utils"; import { useDevice } from "./App"; import ConfirmDialog from "./ConfirmDialog"; -import { close, exportToFileIcon, load, publishIcon, trash } from "./icons"; +import { exportToFileIcon, load, publishIcon, trash } from "./icons"; import { LibraryUnit } from "./LibraryUnit"; import Stack from "./Stack"; import { ToolButton } from "./ToolButton"; @@ -23,9 +23,7 @@ import "./LibraryMenuItems.scss"; import { MIME_TYPES, VERSIONS } from "../constants"; import Spinner from "./Spinner"; import { fileOpen } from "../data/filesystem"; - -import { SidebarLockButton } from "./SidebarLockButton"; -import { trackEvent } from "../analytics"; +import { Sidebar } from "./Sidebar/Sidebar"; const LibraryMenuItems = ({ isLoading, @@ -372,54 +370,6 @@ const LibraryMenuItems = ({ (item) => item.status === "published", ); - 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 ( {showRemoveLibAlert && renderRemoveLibAlert()} - {renderLibraryHeader()} + {/* NOTE using SidebarHeader here isn't semantic since this may render + outside of a sidebar, but for now it doesn't matter */} + + {renderLibraryActions()} + {renderLibraryMenuItems()} {!device.isMobile && renderLibraryFooter()}
diff --git a/src/components/MobileMenu.tsx b/src/components/MobileMenu.tsx index 1e1fdb57..d379c474 100644 --- a/src/components/MobileMenu.tsx +++ b/src/components/MobileMenu.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { AppState, ExcalidrawProps } from "../types"; +import { AppState, Device, ExcalidrawProps } from "../types"; import { ActionManager } from "../actions/manager"; import { t } from "../i18n"; import Stack from "./Stack"; @@ -44,6 +44,8 @@ type MobileMenuProps = { appState: AppState, ) => JSX.Element | null; renderCustomStats?: ExcalidrawProps["renderCustomStats"]; + renderCustomSidebar?: ExcalidrawProps["renderSidebar"]; + device: Device; }; export const MobileMenu = ({ @@ -63,6 +65,8 @@ export const MobileMenu = ({ onImageAction, renderTopRightUI, renderCustomStats, + renderCustomSidebar, + device, }: MobileMenuProps) => { const renderToolbar = () => { return ( @@ -107,11 +111,16 @@ export const MobileMenu = ({ penDetected={appState.penDetected} /> - {libraryMenu} + {libraryMenu && {libraryMenu}} )} - + ); }; @@ -175,6 +184,7 @@ export const MobileMenu = ({ }; return ( <> + {appState.openSidebar === "customSidebar" && renderCustomSidebar?.()} {!appState.viewModeEnabled && renderToolbar()} {!appState.openMenu && appState.showStats && ( { diff --git a/src/components/Sidebar/Sidebar.scss b/src/components/Sidebar/Sidebar.scss new file mode 100644 index 00000000..b3a19ffc --- /dev/null +++ b/src/components/Sidebar/Sidebar.scss @@ -0,0 +1,89 @@ +@import "open-color/open-color"; +@import "../../css/variables.module"; + +.excalidraw { + .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); + + padding: 0.5rem; + box-sizing: border-box; + + .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; + } + } + + .layer-ui__sidebar__header { + display: flex; + align-items: center; + width: 100%; + margin: 2px 0 15px 0; + &:empty { + margin: 0; + } + button { + // 2px from the left to account for focus border of left-most button + margin: 0 2px; + } + } + + .layer-ui__sidebar__header__buttons { + display: flex; + align-items: center; + margin-left: auto; + } + + .layer-ui__sidebar-dock-button { + @include toolbarButtonColorStates; + margin-right: 0.2rem; + + .ToolIcon_type_floating .ToolIcon__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 + .ToolIcon__icon { + background-color: var(--color-primary); + } + } + } +} diff --git a/src/components/Sidebar/Sidebar.test.tsx b/src/components/Sidebar/Sidebar.test.tsx new file mode 100644 index 00000000..542573e1 --- /dev/null +++ b/src/components/Sidebar/Sidebar.test.tsx @@ -0,0 +1,355 @@ +import React from "react"; +import { Excalidraw, Sidebar } from "../../packages/excalidraw/index"; +import { + act, + fireEvent, + queryAllByTestId, + queryByTestId, + render, + waitFor, + withExcalidrawDimensions, +} from "../../tests/test-utils"; + +describe("Sidebar", () => { + it("should render custom sidebar", async () => { + const { container } = await render( + ( + +
42
+
+ )} + />, + ); + + const node = container.querySelector("#test-sidebar-content"); + expect(node).not.toBe(null); + }); + + it("should render custom sidebar header", async () => { + const { container } = await render( + ( + + +
42
+
+
+ )} + />, + ); + + const node = container.querySelector("#test-sidebar-header-content"); + expect(node).not.toBe(null); + // make sure we don't render the default fallback header, + // just the custom one + expect(queryAllByTestId(container, "sidebar-header").length).toBe(1); + }); + + it("should render only one sidebar and prefer the custom one", async () => { + const { container } = await render( + ( + +
42
+
+ )} + />, + ); + + await waitFor(() => { + // make sure the custom sidebar is rendered + const node = container.querySelector("#test-sidebar-content"); + expect(node).not.toBe(null); + + // make sure only one sidebar is rendered + const sidebars = container.querySelectorAll(".layer-ui__sidebar"); + expect(sidebars.length).toBe(1); + }); + }); + + it("should always render custom sidebar with close button & close on click", async () => { + const onClose = jest.fn(); + const CustomExcalidraw = () => { + return ( + ( + + hello + + )} + /> + ); + }; + + const { container } = await render(); + + const sidebar = container.querySelector(".test-sidebar"); + expect(sidebar).not.toBe(null); + const closeButton = queryByTestId(sidebar!, "sidebar-close"); + expect(closeButton).not.toBe(null); + + fireEvent.click(closeButton!.querySelector("button")!); + await waitFor(() => { + expect(container.querySelector(".test-sidebar")).toBe(null); + expect(onClose).toHaveBeenCalled(); + }); + }); + + it("should render custom sidebar with dock (irrespective of onDock prop)", async () => { + const CustomExcalidraw = () => { + return ( + ( + hello + )} + /> + ); + }; + + const { container } = await render(); + + // should show dock button when the sidebar fits to be docked + // ------------------------------------------------------------------------- + + await withExcalidrawDimensions({ width: 1920, height: 1080 }, () => { + const sidebar = container.querySelector(".test-sidebar"); + expect(sidebar).not.toBe(null); + const closeButton = queryByTestId(sidebar!, "sidebar-dock"); + expect(closeButton).not.toBe(null); + }); + + // should not show dock button when the sidebar does not fit to be docked + // ------------------------------------------------------------------------- + + await withExcalidrawDimensions({ width: 400, height: 1080 }, () => { + const sidebar = container.querySelector(".test-sidebar"); + expect(sidebar).not.toBe(null); + const closeButton = queryByTestId(sidebar!, "sidebar-dock"); + expect(closeButton).toBe(null); + }); + }); + + it("should support controlled docking", async () => { + let _setDockable: (dockable: boolean) => void = null!; + + const CustomExcalidraw = () => { + const [dockable, setDockable] = React.useState(false); + _setDockable = setDockable; + return ( + ( + + hello + + )} + /> + ); + }; + + const { container } = await render(); + + await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => { + // should not show dock button when `dockable` is `false` + // ------------------------------------------------------------------------- + + act(() => { + _setDockable(false); + }); + + await waitFor(() => { + const sidebar = container.querySelector(".test-sidebar"); + expect(sidebar).not.toBe(null); + const closeButton = queryByTestId(sidebar!, "sidebar-dock"); + expect(closeButton).toBe(null); + }); + + // should show dock button when `dockable` is `true`, even if `docked` + // prop is set + // ------------------------------------------------------------------------- + + act(() => { + _setDockable(true); + }); + + await waitFor(() => { + const sidebar = container.querySelector(".test-sidebar"); + expect(sidebar).not.toBe(null); + const closeButton = queryByTestId(sidebar!, "sidebar-dock"); + expect(closeButton).not.toBe(null); + }); + }); + }); + + it("should support controlled docking", async () => { + let _setDocked: (docked?: boolean) => void = null!; + + const CustomExcalidraw = () => { + const [docked, setDocked] = React.useState(); + _setDocked = setDocked; + return ( + ( + + hello + + )} + /> + ); + }; + + const { container } = await render(); + + const { h } = window; + + await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => { + const dockButton = await waitFor(() => { + const sidebar = container.querySelector(".test-sidebar"); + expect(sidebar).not.toBe(null); + const dockBotton = queryByTestId(sidebar!, "sidebar-dock"); + expect(dockBotton).not.toBe(null); + return dockBotton!; + }); + + const dockButtonInput = dockButton.querySelector("input")!; + + // should not show dock button when `dockable` is `false` + // ------------------------------------------------------------------------- + + expect(h.state.isSidebarDocked).toBe(false); + + fireEvent.click(dockButtonInput); + await waitFor(() => { + expect(h.state.isSidebarDocked).toBe(true); + expect(dockButtonInput).toBeChecked(); + }); + + fireEvent.click(dockButtonInput); + await waitFor(() => { + expect(h.state.isSidebarDocked).toBe(false); + expect(dockButtonInput).not.toBeChecked(); + }); + + // shouldn't update `appState.isSidebarDocked` when the sidebar + // is controlled (`docked` prop is set), as host apps should handle + // the state themselves + // ------------------------------------------------------------------------- + + act(() => { + _setDocked(true); + }); + + await waitFor(() => { + expect(dockButtonInput).toBeChecked(); + expect(h.state.isSidebarDocked).toBe(false); + expect(dockButtonInput).toBeChecked(); + }); + + fireEvent.click(dockButtonInput); + await waitFor(() => { + expect(h.state.isSidebarDocked).toBe(false); + expect(dockButtonInput).toBeChecked(); + }); + + // the `appState.isSidebarDocked` should remain untouched when + // `props.docked` is set to `false`, and user toggles + // ------------------------------------------------------------------------- + + act(() => { + _setDocked(false); + h.setState({ isSidebarDocked: true }); + }); + + await waitFor(() => { + expect(h.state.isSidebarDocked).toBe(true); + expect(dockButtonInput).not.toBeChecked(); + }); + + fireEvent.click(dockButtonInput); + await waitFor(() => { + expect(dockButtonInput).not.toBeChecked(); + expect(h.state.isSidebarDocked).toBe(true); + }); + }); + }); + + it("should toggle sidebar using props.toggleMenu()", async () => { + const { container } = await render( + ( + +
42
+
+ )} + />, + ); + + // sidebar isn't rendered initially + // ------------------------------------------------------------------------- + await waitFor(() => { + const node = container.querySelector("#test-sidebar-content"); + expect(node).toBe(null); + }); + + // toggle sidebar on + // ------------------------------------------------------------------------- + expect(window.h.app.toggleMenu("customSidebar")).toBe(true); + + await waitFor(() => { + const node = container.querySelector("#test-sidebar-content"); + expect(node).not.toBe(null); + }); + + // toggle sidebar off + // ------------------------------------------------------------------------- + expect(window.h.app.toggleMenu("customSidebar")).toBe(false); + + await waitFor(() => { + const node = container.querySelector("#test-sidebar-content"); + expect(node).toBe(null); + }); + + // force-toggle sidebar off (=> still hidden) + // ------------------------------------------------------------------------- + expect(window.h.app.toggleMenu("customSidebar", false)).toBe(false); + + await waitFor(() => { + const node = container.querySelector("#test-sidebar-content"); + expect(node).toBe(null); + }); + + // force-toggle sidebar on + // ------------------------------------------------------------------------- + expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true); + expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true); + + await waitFor(() => { + const node = container.querySelector("#test-sidebar-content"); + expect(node).not.toBe(null); + }); + + // toggle library (= hide custom sidebar) + // ------------------------------------------------------------------------- + expect(window.h.app.toggleMenu("library")).toBe(true); + + await waitFor(() => { + const node = container.querySelector("#test-sidebar-content"); + expect(node).toBe(null); + + // make sure only one sidebar is rendered + const sidebars = container.querySelectorAll(".layer-ui__sidebar"); + expect(sidebars.length).toBe(1); + }); + }); +}); diff --git a/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx new file mode 100644 index 00000000..bcdced95 --- /dev/null +++ b/src/components/Sidebar/Sidebar.tsx @@ -0,0 +1,121 @@ +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { Island } from ".././Island"; +import { atom, useAtom } from "jotai"; +import { jotaiScope } from "../../jotai"; +import { + SidebarPropsContext, + SidebarProps, + SidebarPropsContextValue, +} from "./common"; + +import { SidebarHeaderComponents } from "./SidebarHeader"; + +import "./Sidebar.scss"; +import clsx from "clsx"; +import { useExcalidrawSetAppState } from "../App"; +import { updateObject } from "../../utils"; + +/** using a counter instead of boolean to handle race conditions where + * the host app may render (mount/unmount) multiple different sidebar */ +export const hostSidebarCountersAtom = atom({ rendered: 0, docked: 0 }); + +export const Sidebar = ({ + children, + onClose, + onDock, + docked, + dockable = true, + className, + __isInternal, +}: SidebarProps<{ + // NOTE sidebars we use internally inside the editor must have this flag set. + // It indicates that this sidebar should have lower precedence over host + // sidebars, if both are open. + /** @private internal */ + __isInternal?: boolean; +}>) => { + const [hostSidebarCounters, setHostSidebarCounters] = useAtom( + hostSidebarCountersAtom, + jotaiScope, + ); + + const setAppState = useExcalidrawSetAppState(); + + const [isDockedFallback, setIsDockedFallback] = useState(docked ?? false); + + useLayoutEffect(() => { + if (docked === undefined) { + // ugly hack to get initial state out of AppState without susbcribing + // to it as a whole (once we have granular subscriptions, we'll move + // to that) + // + // NOTE this means that is updated `state.isSidebarDocked` changes outside + // of this compoent, it won't be reflected here. Currently doesn't happen. + setAppState((state) => { + setIsDockedFallback(state.isSidebarDocked); + // bail from update + return null; + }); + } + }, [setAppState, docked]); + + useLayoutEffect(() => { + if (!__isInternal) { + setHostSidebarCounters((s) => ({ + rendered: s.rendered + 1, + docked: isDockedFallback ? s.docked + 1 : s.docked, + })); + return () => { + setHostSidebarCounters((s) => ({ + rendered: s.rendered - 1, + docked: isDockedFallback ? s.docked - 1 : s.docked, + })); + }; + } + }, [__isInternal, setHostSidebarCounters, isDockedFallback]); + + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + useEffect(() => { + return () => { + onCloseRef.current?.(); + }; + }, []); + + const headerPropsRef = useRef({}); + headerPropsRef.current.onClose = () => { + setAppState({ openSidebar: null }); + }; + headerPropsRef.current.onDock = (isDocked) => { + if (docked === undefined) { + setAppState({ isSidebarDocked: isDocked }); + setIsDockedFallback(isDocked); + } + onDock?.(isDocked); + }; + // renew the ref object if the following props change since we want to + // rerender. We can't pass down as component props manually because + // the can be rendered upsream. + headerPropsRef.current = updateObject(headerPropsRef.current, { + docked: docked ?? isDockedFallback, + dockable, + }); + + if (hostSidebarCounters.rendered > 0 && __isInternal) { + return null; + } + + return ( + + + + + {children} + + + + ); +}; + +Sidebar.Header = SidebarHeaderComponents.Component; diff --git a/src/components/Sidebar/SidebarHeader.tsx b/src/components/Sidebar/SidebarHeader.tsx new file mode 100644 index 00000000..3c8ccc12 --- /dev/null +++ b/src/components/Sidebar/SidebarHeader.tsx @@ -0,0 +1,95 @@ +import clsx from "clsx"; +import { useContext } from "react"; +import { t } from "../../i18n"; +import { useDevice } from "../App"; +import { SidebarPropsContext } from "./common"; +import { close } from "../icons"; +import { withUpstreamOverride } from "../hoc/withUpstreamOverride"; +import { Tooltip } from "../Tooltip"; + +const SIDE_LIBRARY_TOGGLE_ICON = ( + + + +); + +export const SidebarDockButton = (props: { + checked: boolean; + onChange?(): void; +}) => { + return ( +
+ + {" "} + +
+ ); +}; + +const _SidebarHeader: React.FC<{ + children?: React.ReactNode; + className?: string; +}> = ({ children, className }) => { + const device = useDevice(); + const props = useContext(SidebarPropsContext); + + const renderDockButton = !!(device.canDeviceFitSidebar && props.dockable); + const renderCloseButton = !!props.onClose; + + return ( +
+ {children} + {(renderDockButton || renderCloseButton) && ( +
+ {renderDockButton && ( + { + document + .querySelector(".layer-ui__wrapper") + ?.classList.add("animate"); + + props.onDock?.(!props.docked); + }} + /> + )} + {renderCloseButton && ( +
+ +
+ )} +
+ )} +
+ ); +}; + +const [Context, Component] = withUpstreamOverride(_SidebarHeader); + +/** @private */ +export const SidebarHeaderComponents = { Context, Component }; diff --git a/src/components/Sidebar/common.ts b/src/components/Sidebar/common.ts new file mode 100644 index 00000000..e2b310b7 --- /dev/null +++ b/src/components/Sidebar/common.ts @@ -0,0 +1,22 @@ +import React from "react"; + +export type SidebarProps

= { + children: React.ReactNode; + /** + * Called on sidebar close (either by user action or by the editor). + */ + onClose?: () => void | boolean; + /** if not supplied, sidebar won't be dockable */ + onDock?: (docked: boolean) => void; + docked?: boolean; + dockable?: boolean; + className?: string; +} & P; + +export type SidebarPropsContextValue = Pick< + SidebarProps, + "onClose" | "onDock" | "docked" | "dockable" +>; + +export const SidebarPropsContext = + React.createContext({}); diff --git a/src/components/SidebarLockButton.scss b/src/components/SidebarLockButton.scss deleted file mode 100644 index 0e6799a3..00000000 --- a/src/components/SidebarLockButton.scss +++ /dev/null @@ -1,22 +0,0 @@ -@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 deleted file mode 100644 index 2730c982..00000000 --- a/src/components/SidebarLockButton.tsx +++ /dev/null @@ -1,46 +0,0 @@ -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 ( - -

- {SIDE_LIBRARY_TOGGLE_ICON} -
{" "} - {" "} - - ); -}; diff --git a/src/components/hoc/withUpstreamOverride.tsx b/src/components/hoc/withUpstreamOverride.tsx new file mode 100644 index 00000000..acbc800b --- /dev/null +++ b/src/components/hoc/withUpstreamOverride.tsx @@ -0,0 +1,63 @@ +import React, { + useMemo, + useContext, + useLayoutEffect, + useState, + createContext, +} from "react"; + +export const withUpstreamOverride = (Component: React.ComponentType

) => { + type ContextValue = [boolean, React.Dispatch>]; + + const DefaultComponentContext = createContext([ + false, + () => {}, + ]); + + const ComponentContext: React.FC<{ children: React.ReactNode }> = ({ + children, + }) => { + const [isRenderedUpstream, setIsRenderedUpstream] = useState(false); + const contextValue: ContextValue = useMemo( + () => [isRenderedUpstream, setIsRenderedUpstream], + [isRenderedUpstream], + ); + + return ( + + {children} + + ); + }; + + const DefaultComponent = ( + props: P & { + // indicates whether component should render when not rendered upstream + /** @private internal */ + __isFallback?: boolean; + }, + ) => { + const [isRenderedUpstream, setIsRenderedUpstream] = useContext( + DefaultComponentContext, + ); + + useLayoutEffect(() => { + if (!props.__isFallback) { + setIsRenderedUpstream(true); + return () => setIsRenderedUpstream(false); + } + }, [props.__isFallback, setIsRenderedUpstream]); + + if (props.__isFallback && isRenderedUpstream) { + return null; + } + + return ; + }; + if (Component.name) { + DefaultComponent.displayName = `${Component.name}_upstreamOverrideWrapper`; + ComponentContext.displayName = `${Component.name}_upstreamOverrideContextWrapper`; + } + + return [ComponentContext, DefaultComponent] as const; +}; diff --git a/src/data/library.ts b/src/data/library.ts index d01d5a3b..e1b4fde7 100644 --- a/src/data/library.ts +++ b/src/data/library.ts @@ -148,7 +148,7 @@ class Library { defaultStatus?: "unpublished" | "published"; }): Promise => { if (openLibraryMenu) { - this.app.setState({ isLibraryOpen: true }); + this.app.setState({ openSidebar: "library" }); } return this.setLibrary(() => { diff --git a/src/data/restore.ts b/src/data/restore.ts index 427759f6..c04164ab 100644 --- a/src/data/restore.ts +++ b/src/data/restore.ts @@ -9,7 +9,7 @@ import { LibraryItem, NormalizedZoomValue, } from "../types"; -import { ImportedDataState } from "./types"; +import { ImportedDataState, LegacyAppState } from "./types"; import { getNonDeletedElements, getNormalizedDimensions, @@ -251,6 +251,43 @@ export const restoreElements = ( }, [] as ExcalidrawElement[]); }; +const coalesceAppStateValue = < + T extends keyof ReturnType, +>( + key: T, + appState: Exclude, + defaultAppState: ReturnType, +) => { + const value = appState[key]; + // NOTE the value! assertion is needed in TS 4.5.5 (fixed in newer versions) + return value !== undefined ? value! : defaultAppState[key]; +}; + +const LegacyAppStateMigrations: { + [K in keyof LegacyAppState]: ( + ImportedDataState: Exclude, + defaultAppState: ReturnType, + ) => [LegacyAppState[K][1], AppState[LegacyAppState[K][1]]]; +} = { + isLibraryOpen: (appState, defaultAppState) => { + return [ + "openSidebar", + "isLibraryOpen" in appState + ? appState.isLibraryOpen + ? "library" + : null + : coalesceAppStateValue("openSidebar", appState, defaultAppState), + ]; + }, + isLibraryMenuDocked: (appState, defaultAppState) => { + return [ + "isSidebarDocked", + appState.isLibraryMenuDocked ?? + coalesceAppStateValue("isSidebarDocked", appState, defaultAppState), + ]; + }, +}; + export const restoreAppState = ( appState: ImportedDataState["appState"], localAppState: Partial | null | undefined, @@ -258,11 +295,30 @@ export const restoreAppState = ( appState = appState || {}; const defaultAppState = getDefaultAppState(); const nextAppState = {} as typeof defaultAppState; + + // first, migrate all legacy AppState properties to new ones. We do it + // in one go before migrate the rest of the properties in case the new ones + // depend on checking any other key (i.e. they are coupled) + for (const legacyKey of Object.keys( + LegacyAppStateMigrations, + ) as (keyof typeof LegacyAppStateMigrations)[]) { + if (legacyKey in appState) { + const [nextKey, nextValue] = LegacyAppStateMigrations[legacyKey]( + appState, + defaultAppState, + ); + (nextAppState as any)[nextKey] = nextValue; + } + } + for (const [key, defaultValue] of Object.entries(defaultAppState) as [ keyof typeof defaultAppState, any, ][]) { + // if AppState contains a legacy key, prefer that one and migrate its + // value to the new one const suppliedValue = appState[key]; + const localValue = localAppState ? localAppState[key] : undefined; (nextAppState as any)[key] = suppliedValue !== undefined @@ -299,9 +355,12 @@ export const restoreAppState = ( : 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, + openSidebar: + nextAppState.openSidebar === "library" + ? nextAppState.isSidebarDocked + ? "library" + : null + : nextAppState.openSidebar, }; }; diff --git a/src/data/types.ts b/src/data/types.ts index 413e8183..b8c95921 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -17,12 +17,32 @@ export interface ExportedDataState { files: BinaryFiles | undefined; } +/** + * Map of legacy AppState keys, with values of: + * [, ] + * + * This is a helper type used in downstream abstractions. + * Don't consume on its own. + */ +export type LegacyAppState = { + /** @deprecated #5663 TODO remove 22-12-15 */ + isLibraryOpen: [boolean, "openSidebar"]; + /** @deprecated #5663 TODO remove 22-12-15 */ + isLibraryMenuDocked: [boolean, "isSidebarDocked"]; +}; + export interface ImportedDataState { type?: string; version?: number; source?: string; elements?: readonly ExcalidrawElement[] | null; - appState?: Readonly> | null; + appState?: Readonly< + Partial< + AppState & { + [T in keyof LegacyAppState]: LegacyAppState[T][0]; + } + > + > | null; scrollToContent?: boolean; libraryItems?: LibraryItems_anyVersion; files?: BinaryFiles; diff --git a/src/packages/excalidraw/CHANGELOG.md b/src/packages/excalidraw/CHANGELOG.md index 4f886ba5..ab20d0e8 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 +- Support rendering custom sidebar using [`renderSidebar`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#renderSidebar) prop ([#5663](https://github.com/excalidraw/excalidraw/pull/5663)). +- Add [`toggleMenu`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#onMenuToggle) prop to toggle specific menu open/close state ([#5663](https://github.com/excalidraw/excalidraw/pull/5663)). - Support [theme](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#theme) to be semi-controlled [#5660](https://github.com/excalidraw/excalidraw/pull/5660). - Added support for storing [`customData`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#storing-custom-data-to-excalidraw-elements) on Excalidraw elements [#5592]. - Added `exportPadding?: number;` to [exportToCanvas](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#exporttocanvas) and [exportToBlob](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#exporttoblob). The default value of the padding is 10. diff --git a/src/packages/excalidraw/README.md b/src/packages/excalidraw/README.md index 5a188224..552dbd95 100644 --- a/src/packages/excalidraw/README.md +++ b/src/packages/excalidraw/README.md @@ -376,13 +376,17 @@ Most notably, you can customize the primary colors, by overriding these variable For a complete list of variables, check [theme.scss](https://github.com/excalidraw/excalidraw/blob/master/src/css/theme.scss), though most of them will not make sense to override. +### Does this package support collaboration ? + +No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/src/excalidraw-app/index.tsx). + ### Props | Name | Type | Default | Description | | --- | --- | --- | --- | | [`onChange`](#onChange) | Function | | This callback is triggered whenever the component updates due to any change. This callback will receive the excalidraw elements and the current app state. | -| [`initialData`](#initialData) |

{elements?: ExcalidrawElement[], appState?: AppState } 
| null | The initial data with which app loads. | -| [`ref`](#ref) | [`createRef`](https://reactjs.org/docs/refs-and-the-dom.html#creating-refs) | [`useRef`](https://reactjs.org/docs/hooks-reference.html#useref) | [`callbackRef`](https://reactjs.org/docs/refs-and-the-dom.html#callback-refs) |
{ current: { readyPromise: resolvablePromise } }
| | Ref to be passed to Excalidraw | +| [`initialData`](#initialData) | {elements?: ExcalidrawElement[], appState?: AppState } | null | The initial data with which app loads. | +| [`ref`](#ref) | [`createRef`](https://reactjs.org/docs/refs-and-the-dom.html#creating-refs) | [`useRef`](https://reactjs.org/docs/hooks-reference.html#useref) | [`callbackRef`](https://reactjs.org/docs/refs-and-the-dom.html#callback-refs) | { current: { readyPromise: resolvablePromise } } | | Ref to be passed to Excalidraw | | [`onCollabButtonClick`](#onCollabButtonClick) | Function | | Callback to be triggered when the collab button is clicked | | [`isCollaborating`](#isCollaborating) | `boolean` | | This implies if the app is in collaboration mode | | [`onPointerUpdate`](#onPointerUpdate) | Function | | Callback triggered when mouse pointer is updated. | @@ -390,22 +394,23 @@ For a complete list of variables, check [theme.scss](https://github.com/excalidr | [`renderTopRightUI`](#renderTopRightUI) | Function | | Function that renders custom UI in top right corner | | [`renderFooter `](#renderFooter) | Function | | Function that renders custom UI footer | | [`renderCustomStats`](#renderCustomStats) | Function | | Function that can be used to render custom stats on the stats dialog. | +| [`renderSIdebar`](#renderSIdebar) | Function | | Render function that renders custom sidebar. | | [`viewModeEnabled`](#viewModeEnabled) | boolean | | This implies if the app is in view mode. | | [`zenModeEnabled`](#zenModeEnabled) | boolean | | This implies if the zen mode is enabled | | [`gridModeEnabled`](#gridModeEnabled) | boolean | | This implies if the grid mode is enabled | | [`libraryReturnUrl`](#libraryReturnUrl) | string | | What URL should [libraries.excalidraw.com](https://libraries.excalidraw.com) be installed to | | [`theme`](#theme) | [THEME.LIGHT](#THEME-1) | [THEME.DARK](#THEME-1) | [THEME.LIGHT](#THEME-1) | The theme of the Excalidraw component | | [`name`](#name) | string | | Name of the drawing | -| [`UIOptions`](#UIOptions) |
{ canvasActions:  CanvasActions }
| [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/src/constants.ts#L129) | To customise UI options. Currently we support customising [`canvas actions`](#canvasActions) | -| [`onPaste`](#onPaste) |
(data: ClipboardData, event: ClipboardEvent | null) => boolean
| | Callback to be triggered if passed when the something is pasted in to the scene | +| [`UIOptions`](#UIOptions) | { canvasActions: CanvasActions } | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/src/constants.ts#L129) | To customise UI options. Currently we support customising [`canvas actions`](#canvasActions) | +| [`onPaste`](#onPaste) | (data: ClipboardData, event: ClipboardEvent | null) => boolean | | Callback to be triggered if passed when the something is pasted in to the scene | | [`detectScroll`](#detectScroll) | boolean | true | Indicates whether to update the offsets when nearest ancestor is scrolled. | | [`handleKeyboardGlobally`](#handleKeyboardGlobally) | boolean | false | Indicates whether to bind the keyboard events to document. | -| [`onLibraryChange`](#onLibraryChange) |
(items: LibraryItems) => void | Promise<any> 
| | The callback if supplied is triggered when the library is updated and receives the library items. | +| [`onLibraryChange`](#onLibraryChange) | (items: LibraryItems) => void | Promise<any> | | The callback if supplied is triggered when the library is updated and receives the library items. | | [`autoFocus`](#autoFocus) | boolean | false | Implies whether to focus the Excalidraw component on page load | | [`generateIdForFile`](#generateIdForFile) | `(file: File) => string | Promise` | Allows you to override `id` generation for files added on canvas | -| [`onLinkOpen`](#onLinkOpen) |
(element: NonDeletedExcalidrawElement, event: CustomEvent) 
| | This prop if passed will be triggered when link of an element is clicked. | -| [`onPointerDown`](#onPointerDown) |
(activeTool:  AppState["activeTool"], pointerDownState: PointerDownState) => void
| | This prop if passed gets triggered on pointer down evenets | -| [`onScrollChange`](#onScrollChange) | (scrollX: number, scrollY: number) | | This prop if passed gets triggered when scrolling the canvas. | +| [`onLinkOpen`](#onLinkOpen) | (element: NonDeletedExcalidrawElement, event: CustomEvent) | | This prop if passed will be triggered when link of an element is clicked. | +| [`onPointerDown`](#onPointerDown) | (activeTool: AppState["activeTool"], pointerDownState: PointerDownState) => void | | This prop if passed gets triggered on pointer down evenets | +| [`onScrollChange`](#onScrollChange) | (scrollX: number, scrollY: number) | | This prop if passed gets triggered when scrolling the canvas. | ### Dimensions of Excalidraw @@ -503,6 +508,7 @@ You can pass a `ref` when you want to access some excalidraw APIs. We expose the | [setActiveTool](#setActiveTool) | (tool: { type: typeof SHAPES [number]["value"]| "eraser" } | { type: "custom"; customType: string }) => void | This API can be used to set the active tool | | [setCursor](#setCursor) | (cursor: string) => void | This API can be used to set customise the mouse cursor on the canvas | | [resetCursor](#resetCursor) | () => void | This API can be used to reset to default mouse cursor on the canvas | +| [toggleMenu](#toggleMenu) | (type: string, force?: boolean) => boolean | Toggles specific menus on/off | #### `readyPromise` @@ -619,6 +625,38 @@ A function returning JSX to render custom UI footer. For example, you can use th A function that can be used to render custom stats (returns JSX) in the nerd stats dialog. For example you can use this prop to render the size of the elements in the storage. +#### `renderSidebar` + +
+() => JSX | null
+
+ +Optional function that can render custom sidebar. This sidebar is the same that the library menu sidebar is using, and can be used for any purposes your app needs. The render function should return a `` instance — a component that is exported from the Excalidraw package. It accepts `children` which can be any content you like to render inside. + +The `` component takes these props (all are optional except `children`): + +| name | type | description | +| --- | --- | --- | +| className | string | +| children |
React.ReactNode
| Content you want to render inside the sidebar. | +| onClose |
() => void
| Invoked when the component is closed (by user, or the editor). No need to act on this event, as the editor manages the sidebar open state on its own. | +| onDock |
(isDocked: boolean) => void
| Invoked when the user toggles the dock button. | +| docked | boolean | Indicates whether the sidebar is docked. By default, the sidebar is undocked. If passed, the docking becomes controlled, and you are responsible for updating the `docked` state by listening on `onDock` callback. See [here](#dockedSidebarBreakpoint) for more info docking. | +| dockable | boolean | Indicates whether the sidebar can be docked by user (=the dock button is shown). If `false`, you can still dock programmatically by passing `docked=true` | + +The sidebar will always include a header with close/dock buttons (when applicable). + +You can also add custom content to the header, by rendering `` as a child of the `` component. Note that the custom header will still include the default buttons. + +The `` component takes these props children (all are optional): + +| name | type | description | +| --- | --- | --- | +| className | string | +| children |
React.ReactNode
| Content you want to render inside the sidebar header, sibling of the header buttons. | + +For example code, see the example [`App.tsx`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/example/App.tsx#L524) file. + #### `viewModeEnabled` This prop indicates whether the app is in `view mode`. When supplied, the value takes precedence over `intialData.appState.viewModeEnabled`, the `view mode` will be fully controlled by the host app, and users won't be able to toggle it from within the app. @@ -753,6 +791,16 @@ This API can be used to customise the mouse cursor on the canvas and has the bel (cursor: string) => void +#### `toggleMenu` + +
+(type: "library" | "customSidebar", force?: boolean) => boolean
+
+ +This API can be used to toggle a specific menu (currently only the sidebars), and returns whether the menu was toggled on or off. If the `force` flag passed, it will force the menu to be toggled either on/off based on the boolean passed. + +This API is especially useful when you render a custom sidebar using [`renderSidebar`](#renderSidebar) prop, and you want to toggle it from your app based on a user action. + #### `resetCursor` This API can be used to reset to default mouse cursor. @@ -842,10 +890,6 @@ This prop if passed will be triggered when canvas is scrolled and has the below (scrollX: number, scrollY: number) => void ``` -### Does it support collaboration ? - -No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/src/excalidraw-app/index.tsx). - ### Restore utilities #### `restoreAppState` diff --git a/src/packages/excalidraw/example/App.tsx b/src/packages/excalidraw/example/App.tsx index 5d97bb48..7608c32c 100644 --- a/src/packages/excalidraw/example/App.tsx +++ b/src/packages/excalidraw/example/App.tsx @@ -1,6 +1,8 @@ import { useEffect, useState, useRef, useCallback } from "react"; -import Sidebar from "./sidebar/Sidebar"; +import ExampleSidebar from "./sidebar/ExampleSidebar"; + +import type * as TExcalidraw from "../index"; import "./App.scss"; import initialData from "./initialData"; @@ -24,15 +26,12 @@ import { LibraryItems, PointerDownState as ExcalidrawPointerDownState, } from "../../../types"; -import { - ExcalidrawElement, - NonDeletedExcalidrawElement, -} from "../../../element/types"; +import { NonDeletedExcalidrawElement } from "../../../element/types"; import { ImportedLibraryData } from "../../../data/types"; declare global { interface Window { - ExcalidrawLib: any; + ExcalidrawLib: typeof TExcalidraw; } } @@ -68,6 +67,7 @@ const { sceneCoordsToViewportCoords, viewportCoordsToSceneCoords, restoreElements, + Sidebar, } = window.ExcalidrawLib; const COMMENT_SVG = ( @@ -275,11 +275,14 @@ export default function App() { [], ); - const onCopy = async (type: string) => { + const onCopy = async (type: "png" | "svg" | "json") => { + if (!excalidrawAPI) { + return false; + } await exportToClipboard({ - elements: excalidrawAPI?.getSceneElements(), - appState: excalidrawAPI?.getAppState(), - files: excalidrawAPI?.getFiles(), + elements: excalidrawAPI.getSceneElements(), + appState: excalidrawAPI.getAppState(), + files: excalidrawAPI.getFiles(), type, }); window.alert(`Copied to clipboard as ${type} successfully`); @@ -302,12 +305,15 @@ export default function App() { }; const rerenderCommentIcons = () => { + if (!excalidrawAPI) { + return false; + } const commentIconsElements = appRef.current.querySelectorAll( ".comment-icon", ) as HTMLElement[]; commentIconsElements.forEach((ele) => { const id = ele.id; - const appstate = excalidrawAPI?.getAppState(); + const appstate = excalidrawAPI.getAppState(); const { x, y } = sceneCoordsToViewportCoords( { sceneX: commentIcons[id].x, sceneY: commentIcons[id].y }, appstate, @@ -325,12 +331,15 @@ export default function App() { pointerDownState: PointerDownState, ) => { return withBatchedUpdatesThrottled((event) => { + if (!excalidrawAPI) { + return false; + } const { x, y } = viewportCoordsToSceneCoords( { clientX: event.clientX - pointerDownState.hitElementOffsets.x, clientY: event.clientY - pointerDownState.hitElementOffsets.y, }, - excalidrawAPI?.getAppState(), + excalidrawAPI.getAppState(), ); setCommentIcons({ ...commentIcons, @@ -371,10 +380,13 @@ export default function App() { }; const renderCommentIcons = () => { return Object.values(commentIcons).map((commentIcon) => { - const appState = excalidrawAPI?.getAppState(); + if (!excalidrawAPI) { + return false; + } + const appState = excalidrawAPI.getAppState(); const { x, y } = sceneCoordsToViewportCoords( { sceneX: commentIcon.x, sceneY: commentIcon.y }, - excalidrawAPI?.getAppState(), + excalidrawAPI.getAppState(), ); return (
appState.width) { left = appState.width - COMMENT_INPUT_WIDTH - COMMENT_ICON_DIMENSION / 2; } + return (