diff --git a/src/components/App.tsx b/src/components/App.tsx index edefacb0..da9fba57 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 2cefeed5..2d754c9b 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 59dd299d..37b32cc0 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 6649346d..66993d4d 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 82522601..cd28f7c2 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 c492f27f..47ddf2b2 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 12a936c3..a3e6ff0b 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 00000000..febb66d5 --- /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 8cffe69a..b12a41e3 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 61360534..600f65d4 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 4ac07159..51af0a03 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 2957fd47..3610ac1c 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 d87174a5..e83a4226 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 aef6a7d5..0f991c43 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); +};