diff --git a/src/components/App.tsx b/src/components/App.tsx index edefacb01b..da9fba57b9 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -534,12 +534,8 @@ class App extends React.Component { this.scene.getNonDeletedElements(), this.state, ); - const { - onCollabButtonClick, - renderTopRightUI, - renderFooter, - renderCustomStats, - } = this.props; + const { onCollabButtonClick, renderTopRightUI, renderCustomStats } = + this.props; return (
{ langCode={getLanguage().code} isCollaborating={this.props.isCollaborating} renderTopRightUI={renderTopRightUI} - renderCustomFooter={renderFooter} renderCustomStats={renderCustomStats} renderCustomSidebar={this.props.renderSidebar} showExitZenModeBtn={ @@ -601,7 +596,9 @@ class App extends React.Component { this.state.activeTool.type === "selection" && !this.scene.getElementsIncludingDeleted().length } - /> + > + {this.props.children} +
{selectedElement.length === 1 && diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 2cefeed569..2d754c9ba8 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -8,8 +8,14 @@ import { NonDeletedExcalidrawElement } from "../element/types"; import { Language, t } from "../i18n"; import { calculateScrollCenter } from "../scene"; import { ExportType } from "../scene/types"; -import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types"; -import { muteFSAbortError } from "../utils"; +import { + AppProps, + AppState, + ExcalidrawProps, + BinaryFiles, + UIChildrenComponents, +} from "../types"; +import { muteFSAbortError, ReactChildrenToObject } from "../utils"; import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; import CollabButton from "./CollabButton"; import { ErrorDialog } from "./ErrorDialog"; @@ -38,7 +44,7 @@ import { trackEvent } from "../analytics"; import { isMenuOpenAtom, useDevice } from "../components/App"; import { Stats } from "./Stats"; import { actionToggleStats } from "../actions/actionToggleStats"; -import Footer from "./Footer"; +import Footer from "./footer/Footer"; import { ExportImageIcon, HamburgerMenuIcon, @@ -71,7 +77,6 @@ interface LayerUIProps { langCode: Language["code"]; isCollaborating: boolean; renderTopRightUI?: ExcalidrawProps["renderTopRightUI"]; - renderCustomFooter?: ExcalidrawProps["renderFooter"]; renderCustomStats?: ExcalidrawProps["renderCustomStats"]; renderCustomSidebar?: ExcalidrawProps["renderSidebar"]; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; @@ -81,7 +86,9 @@ interface LayerUIProps { id: string; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; renderWelcomeScreen: boolean; + children?: React.ReactNode; } + const LayerUI = ({ actionManager, appState, @@ -96,7 +103,7 @@ const LayerUI = ({ showExitZenModeBtn, isCollaborating, renderTopRightUI, - renderCustomFooter, + renderCustomStats, renderCustomSidebar, libraryReturnUrl, @@ -106,9 +113,13 @@ const LayerUI = ({ id, onImageAction, renderWelcomeScreen, + children, }: LayerUIProps) => { const device = useDevice(); + const childrenComponents = + ReactChildrenToObject(children); + const renderJSONExportDialog = () => { if (!UIOptions.canvasActions.export) { return null; @@ -481,7 +492,6 @@ const LayerUI = ({ onPenModeToggle={onPenModeToggle} canvas={canvas} isCollaborating={isCollaborating} - renderCustomFooter={renderCustomFooter} onImageAction={onImageAction} renderTopRightUI={renderTopRightUI} renderCustomStats={renderCustomStats} @@ -514,9 +524,11 @@ const LayerUI = ({ renderWelcomeScreen={renderWelcomeScreen} appState={appState} actionManager={actionManager} - renderCustomFooter={renderCustomFooter} showExitZenModeBtn={showExitZenModeBtn} - /> + > + {childrenComponents.FooterCenter} + + {appState.showStats && ( { 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 && diff --git a/src/components/MobileMenu.tsx b/src/components/MobileMenu.tsx index 59dd299d78..37b32cc0a3 100644 --- a/src/components/MobileMenu.tsx +++ b/src/components/MobileMenu.tsx @@ -36,10 +36,7 @@ type MobileMenuProps = { onPenModeToggle: () => void; canvas: HTMLCanvasElement | null; isCollaborating: boolean; - renderCustomFooter?: ( - isMobile: boolean, - appState: AppState, - ) => JSX.Element | null; + onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; renderTopRightUI?: ( isMobile: boolean, @@ -63,7 +60,6 @@ export const MobileMenu = ({ onPenModeToggle, canvas, isCollaborating, - renderCustomFooter, onImageAction, renderTopRightUI, renderCustomStats, @@ -253,7 +249,6 @@ export const MobileMenu = ({
{renderCanvasActions()} - {renderCustomFooter?.(true, appState)} {appState.collaborators.size > 0 && (
{t("labels.collaborators")} diff --git a/src/components/WelcomeScreen.tsx b/src/components/WelcomeScreen.tsx index 6649346dfb..66993d4d75 100644 --- a/src/components/WelcomeScreen.tsx +++ b/src/components/WelcomeScreen.tsx @@ -2,7 +2,7 @@ import { useAtom } from "jotai"; import { actionLoadScene, actionShortcuts } from "../actions"; import { ActionManager } from "../actions/manager"; import { getShortcutFromShortcutName } from "../actions/shortcuts"; -import { COOKIES } from "../constants"; +import { isExcalidrawPlusSignedUser } from "../constants"; import { collabDialogShownAtom } from "../excalidraw-app/collab/Collab"; import { t } from "../i18n"; import { AppState } from "../types"; @@ -15,10 +15,6 @@ import { } from "./icons"; import "./WelcomeScreen.scss"; -const isExcalidrawPlusSignedUser = document.cookie.includes( - COOKIES.AUTH_STATE_COOKIE, -); - const WelcomeScreenItem = ({ label, shortcut, diff --git a/src/components/Footer.tsx b/src/components/footer/Footer.tsx similarity index 77% rename from src/components/Footer.tsx rename to src/components/footer/Footer.tsx index 825226011a..cd28f7c275 100644 --- a/src/components/Footer.tsx +++ b/src/components/footer/Footer.tsx @@ -1,35 +1,37 @@ import clsx from "clsx"; -import { ActionManager } from "../actions/manager"; -import { t } from "../i18n"; -import { AppState, ExcalidrawProps } from "../types"; +import { ActionManager } from "../../actions/manager"; +import { t } from "../../i18n"; +import { AppState } from "../../types"; import { ExitZenModeAction, FinalizeAction, UndoRedoActions, ZoomActions, -} from "./Actions"; -import { useDevice } from "./App"; -import { WelcomeScreenHelpArrow } from "./icons"; -import { Section } from "./Section"; -import Stack from "./Stack"; -import WelcomeScreenDecor from "./WelcomeScreenDecor"; +} from "../Actions"; +import { useDevice } from "../App"; +import { WelcomeScreenHelpArrow } from "../icons"; +import { Section } from "../Section"; +import Stack from "../Stack"; +import WelcomeScreenDecor from "../WelcomeScreenDecor"; +import FooterCenter from "./FooterCenter"; const Footer = ({ appState, actionManager, - renderCustomFooter, showExitZenModeBtn, renderWelcomeScreen, + children, }: { appState: AppState; actionManager: ActionManager; - renderCustomFooter?: ExcalidrawProps["renderFooter"]; showExitZenModeBtn: boolean; renderWelcomeScreen: boolean; + children?: React.ReactNode; }) => { const device = useDevice(); const showFinalize = !appState.viewModeEnabled && appState.multiElement && device.isTouchScreen; + return (
-
- {renderCustomFooter?.(false, appState)} -
+ {children}
{ + const appState = useExcalidrawAppState(); + return ( +
+ {children} +
+ ); +}; + +export default FooterCenter; +FooterCenter.displayName = "FooterCenter"; diff --git a/src/constants.ts b/src/constants.ts index c492f27f6f..47ddf2b299 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -243,3 +243,7 @@ export const COOKIES = { /** key containt id of precedeing elemnt id we use in reconciliation during * collaboration */ export const PRECEDING_ELEMENT_KEY = "__precedingElement__"; + +export const isExcalidrawPlusSignedUser = document.cookie.includes( + COOKIES.AUTH_STATE_COOKIE, +); diff --git a/src/components/EncryptedIcon.tsx b/src/excalidraw-app/components/EncryptedIcon.tsx similarity index 63% rename from src/components/EncryptedIcon.tsx rename to src/excalidraw-app/components/EncryptedIcon.tsx index 12a936c34f..a3e6ff0baf 100644 --- a/src/components/EncryptedIcon.tsx +++ b/src/excalidraw-app/components/EncryptedIcon.tsx @@ -1,8 +1,8 @@ -import { t } from "../i18n"; -import { shield } from "./icons"; -import { Tooltip } from "./Tooltip"; +import { shield } from "../../components/icons"; +import { Tooltip } from "../../components/Tooltip"; +import { t } from "../../i18n"; -const EncryptedIcon = () => ( +export const EncryptedIcon = () => ( ( ); - -export default EncryptedIcon; diff --git a/src/excalidraw-app/components/ExcalidrawPlusAppLink.tsx b/src/excalidraw-app/components/ExcalidrawPlusAppLink.tsx new file mode 100644 index 0000000000..febb66d518 --- /dev/null +++ b/src/excalidraw-app/components/ExcalidrawPlusAppLink.tsx @@ -0,0 +1,17 @@ +import { isExcalidrawPlusSignedUser } from "../../constants"; + +export const ExcalidrawPlusAppLink = () => { + if (!isExcalidrawPlusSignedUser) { + return null; + } + return ( + + Go to Excalidraw+ + + ); +}; diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx index 8cffe69ac4..b12a41e31a 100644 --- a/src/excalidraw-app/index.tsx +++ b/src/excalidraw-app/index.tsx @@ -7,7 +7,6 @@ import { ErrorDialog } from "../components/ErrorDialog"; import { TopErrorBoundary } from "../components/TopErrorBoundary"; import { APP_NAME, - COOKIES, EVENT, THEME, TITLE_TIMEOUT, @@ -22,7 +21,7 @@ import { } from "../element/types"; import { useCallbackRefState } from "../hooks/useCallbackRefState"; import { t } from "../i18n"; -import { Excalidraw, defaultLang } from "../packages/excalidraw/index"; +import { Excalidraw, defaultLang, Footer } from "../packages/excalidraw/index"; import { AppState, LibraryItems, @@ -50,7 +49,6 @@ import Collab, { collabDialogShownAtom, isCollaboratingAtom, } from "./collab/Collab"; -import { LanguageList } from "./components/LanguageList"; import { exportToBackend, getCollaborationLinkData, @@ -79,15 +77,12 @@ import { atom, Provider, useAtom } from "jotai"; import { jotaiStore, useAtomWithInitialValue } from "../jotai"; import { reconcileElements } from "./collab/reconciliation"; import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library"; -import EncryptedIcon from "../components/EncryptedIcon"; +import { EncryptedIcon } from "./components/EncryptedIcon"; +import { ExcalidrawPlusAppLink } from "./components/ExcalidrawPlusAppLink"; polyfill(); window.EXCALIDRAW_THROTTLE_RENDER = true; -const isExcalidrawPlusSignedUser = document.cookie.includes( - COOKIES.AUTH_STATE_COOKIE, -); - const languageDetector = new LanguageDetector(); languageDetector.init({ languageUtils: {}, @@ -577,41 +572,6 @@ const ExcalidrawWrapper = () => { } }; - const renderFooter = (isMobile: boolean) => { - const renderLanguageList = () => ; - if (isMobile) { - return ( -
-
- {t("labels.language")} -
-
{renderLanguageList()}
-
- ); - } - - return ( -
- {isExcalidrawPlusSignedUser && ( - - Go to Excalidraw+ - - )} - -
- ); - }; - const renderCustomStats = ( elements: readonly NonDeletedExcalidrawElement[], appState: AppState, @@ -672,7 +632,6 @@ const ExcalidrawWrapper = () => { }, }, }} - renderFooter={renderFooter} langCode={langCode} renderCustomStats={renderCustomStats} detectScroll={false} @@ -680,7 +639,14 @@ const ExcalidrawWrapper = () => { onLibraryChange={onLibraryChange} autoFocus={true} theme={theme} - /> + > +
+
+ + +
+
+ {excalidrawAPI && } {errorMessage && ( ; +const App = () => { + return ( + +
+ +
+
+ ); +}; +``` + ### Props | Name | Type | Default | Description | @@ -392,7 +417,6 @@ No, Excalidraw package doesn't come with collaboration built in, since the imple | [`onPointerUpdate`](#onPointerUpdate) | Function | | Callback triggered when mouse pointer is updated. | | [`langCode`](#langCode) | string | `en` | Language code string | | [`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. | @@ -613,14 +637,6 @@ import { defaultLang, languages } from "@excalidraw/excalidraw"; A function returning JSX to render custom UI in the top right corner of the app. -#### `renderFooter` - -
-(isMobile: boolean, appState: AppState) => JSX | null
-
- -A function returning JSX to render custom UI footer. For example, you can use this to render a language picker that was previously being rendered by Excalidraw itself (for now, you'll need to implement your own language picker). - #### `renderCustomStats` 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. diff --git a/src/packages/excalidraw/example/App.tsx b/src/packages/excalidraw/example/App.tsx index 613605340d..600f65d4eb 100644 --- a/src/packages/excalidraw/example/App.tsx +++ b/src/packages/excalidraw/example/App.tsx @@ -68,6 +68,7 @@ const { viewportCoordsToSceneCoords, restoreElements, Sidebar, + Footer, } = window.ExcalidrawLib; const COMMENT_SVG = ( @@ -160,49 +161,6 @@ export default function App() { fetchData(); }, [excalidrawAPI]); - const renderFooter = () => { - return ( - <> - {" "} - - - - ); - }; - const loadSceneOrLibrary = async () => { const file = await fileOpen({ description: "Excalidraw or library file" }); const contents = await loadSceneOrLibraryFromBlob(file, null, null); @@ -712,12 +670,49 @@ export default function App() { name="Custom name of drawing" UIOptions={{ canvasActions: { loadScene: false } }} renderTopRightUI={renderTopRightUI} - renderFooter={renderFooter} onLinkOpen={onLinkOpen} onPointerDown={onPointerDown} onScrollChange={rerenderCommentIcons} renderSidebar={renderSidebar} - /> + > +
+ + +
+ {Object.keys(commentIcons || []).length > 0 && renderCommentIcons()} {comment && renderComment()}
diff --git a/src/packages/excalidraw/index.tsx b/src/packages/excalidraw/index.tsx index 4ac0715920..51af0a03e2 100644 --- a/src/packages/excalidraw/index.tsx +++ b/src/packages/excalidraw/index.tsx @@ -10,6 +10,7 @@ import { defaultLang } from "../../i18n"; import { DEFAULT_UI_OPTIONS } from "../../constants"; import { Provider } from "jotai"; import { jotaiScope, jotaiStore } from "../../jotai"; +import Footer from "../../components/footer/FooterCenter"; const ExcalidrawBase = (props: ExcalidrawProps) => { const { @@ -20,7 +21,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { isCollaborating = false, onPointerUpdate, renderTopRightUI, - renderFooter, renderSidebar, langCode = defaultLang.code, viewModeEnabled, @@ -39,6 +39,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { onLinkOpen, onPointerDown, onScrollChange, + children, } = props; const canvasActions = props.UIOptions?.canvasActions; @@ -93,7 +94,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { isCollaborating={isCollaborating} onPointerUpdate={onPointerUpdate} renderTopRightUI={renderTopRightUI} - renderFooter={renderFooter} langCode={langCode} viewModeEnabled={viewModeEnabled} zenModeEnabled={zenModeEnabled} @@ -113,7 +113,9 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { onPointerDown={onPointerDown} onScrollChange={onScrollChange} renderSidebar={renderSidebar} - /> + > + {children} + ); @@ -236,3 +238,4 @@ export { } from "../../utils"; export { Sidebar } from "../../components/Sidebar/Sidebar"; +export { Footer }; diff --git a/src/tests/packages/excalidraw.test.tsx b/src/tests/packages/excalidraw.test.tsx index 2957fd4762..3610ac1c91 100644 --- a/src/tests/packages/excalidraw.test.tsx +++ b/src/tests/packages/excalidraw.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, GlobalTestState, render } from "../test-utils"; -import { Excalidraw } from "../../packages/excalidraw/index"; +import { Excalidraw, Footer } from "../../packages/excalidraw/index"; import { queryByText, queryByTestId } from "@testing-library/react"; import { GRID_SIZE, THEME } from "../../constants"; import { t } from "../../i18n"; @@ -49,6 +49,31 @@ describe("", () => { }); }); + it("should render the footer only when Footer is passed as children", async () => { + //Footer not passed hence it will not render the footer + let { container } = await render( + +
This is a custom footer
+
, + ); + expect( + container.querySelector(".layer-ui__wrapper__footer-center"), + ).toBeEmptyDOMElement(); + + // Footer passed hence it will render the footer + ({ container } = await render( + +
+
This is a custom footer
+
+
, + )); + expect( + container.querySelector(".layer-ui__wrapper__footer-center")?.innerHTML, + ).toMatchInlineSnapshot( + `""`, + ); + }); describe("Test gridModeEnabled prop", () => { it('should show grid mode in context menu when gridModeEnabled is "undefined"', async () => { const { container } = await render(); diff --git a/src/types.ts b/src/types.ts index d87174a5a1..e83a4226ae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -295,7 +295,6 @@ export interface ExcalidrawProps { isMobile: boolean, appState: AppState, ) => JSX.Element | null; - renderFooter?: (isMobile: boolean, appState: AppState) => JSX.Element | null; langCode?: Language["code"]; viewModeEnabled?: boolean; zenModeEnabled?: boolean; @@ -331,6 +330,7 @@ export interface ExcalidrawProps { * Render function that renders custom component. */ renderSidebar?: () => JSX.Element | null; + children?: React.ReactNode; } export type SceneData = { @@ -507,3 +507,9 @@ export type Device = Readonly<{ isTouchScreen: boolean; canDeviceFitSidebar: boolean; }>; + +export type UIChildrenComponents = { + [k in "FooterCenter"]?: + | React.ReactPortal + | React.ReactElement>; +}; diff --git a/src/utils.ts b/src/utils.ts index aef6a7d579..0f991c4374 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -15,6 +15,7 @@ import { AppState, DataURL, LastActiveToolBeforeEraser, Zoom } from "./types"; import { unstable_batchedUpdates } from "react-dom"; import { isDarwin } from "./keys"; import { SHAPES } from "./shapes"; +import React from "react"; let mockDateTime: string | null = null; @@ -686,3 +687,25 @@ export const queryFocusableElements = (container: HTMLElement | null) => { ) : []; }; + +export const ReactChildrenToObject = < + T extends { + [k in string]?: + | React.ReactPortal + | React.ReactElement>; + }, +>( + children: React.ReactNode, +) => { + return React.Children.toArray(children).reduce((acc, child) => { + if ( + React.isValidElement(child) && + typeof child.type !== "string" && + child?.type.name + ) { + // @ts-ignore + acc[child.type.name] = child; + } + return acc; + }, {} as Partial); +};