Browse Source

feat: rewrite public UI component rendering using tunnels (#6117)

* feat: rewrite public UI component rendering using tunnels

* factor out into components

* comments

* fix variable naming

* fix not hiding welcomeScreen

* factor out AppFooter and memoize components

* remove `UIOptions.welcomeScreen` and render only from host app

* factor out tunnels into own file

* update changelog. Keep `UIOptions.welcomeScreen` as deprecated

* update changelog

* lint

---------

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
David Luzar 2 years ago
parent
commit
e6de1fe4a4

+ 1 - 0
package.json

@@ -55,6 +55,7 @@
     "roughjs": "4.5.2",
     "sass": "1.51.0",
     "socket.io-client": "2.3.1",
+    "tunnel-rat": "0.1.0",
     "typescript": "4.9.4",
     "workbox-background-sync": "^6.5.4",
     "workbox-broadcast-update": "^6.5.4",

+ 0 - 2
src/components/App.tsx

@@ -589,7 +589,6 @@ class App extends React.Component<AppProps, AppState> {
                         })
                       }
                       langCode={getLanguage().code}
-                      isCollaborating={this.props.isCollaborating}
                       renderTopRightUI={renderTopRightUI}
                       renderCustomStats={renderCustomStats}
                       renderCustomSidebar={this.props.renderSidebar}
@@ -605,7 +604,6 @@ class App extends React.Component<AppProps, AppState> {
                       onImageAction={this.onImageAction}
                       renderWelcomeScreen={
                         !this.state.isLoading &&
-                        this.props.UIOptions.welcomeScreen &&
                         this.state.showWelcomeScreen &&
                         this.state.activeTool.type === "selection" &&
                         !this.scene.getElementsIncludingDeleted().length

+ 51 - 68
src/components/LayerUI.tsx

@@ -8,15 +8,8 @@ import { NonDeletedExcalidrawElement } from "../element/types";
 import { Language, t } from "../i18n";
 import { calculateScrollCenter } from "../scene";
 import { ExportType } from "../scene/types";
-import {
-  AppProps,
-  AppState,
-  ExcalidrawProps,
-  BinaryFiles,
-  UIChildrenComponents,
-  UIWelcomeScreenComponents,
-} from "../types";
-import { isShallowEqual, muteFSAbortError, getReactChildren } from "../utils";
+import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
+import { isShallowEqual, muteFSAbortError } from "../utils";
 import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
 import { ErrorDialog } from "./ErrorDialog";
 import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
@@ -45,7 +38,6 @@ import { useDevice } from "../components/App";
 import { Stats } from "./Stats";
 import { actionToggleStats } from "../actions/actionToggleStats";
 import Footer from "./footer/Footer";
-import WelcomeScreen from "./welcome-screen/WelcomeScreen";
 import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
 import { jotaiScope } from "../jotai";
 import { useAtom } from "jotai";
@@ -53,6 +45,12 @@ import MainMenu from "./main-menu/MainMenu";
 import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
 import { HandButton } from "./HandButton";
 import { isHandToolActive } from "../appState";
+import {
+  mainMenuTunnel,
+  welcomeScreenMenuHintTunnel,
+  welcomeScreenToolbarHintTunnel,
+  welcomeScreenCenterTunnel,
+} from "./tunnels";
 
 interface LayerUIProps {
   actionManager: ActionManager;
@@ -67,7 +65,6 @@ interface LayerUIProps {
   onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
   showExitZenModeBtn: boolean;
   langCode: Language["code"];
-  isCollaborating: boolean;
   renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
   renderCustomStats?: ExcalidrawProps["renderCustomStats"];
   renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
@@ -81,6 +78,32 @@ interface LayerUIProps {
   children?: React.ReactNode;
 }
 
+const DefaultMainMenu: React.FC<{
+  UIOptions: AppProps["UIOptions"];
+}> = ({ UIOptions }) => {
+  return (
+    <MainMenu __fallback>
+      <MainMenu.DefaultItems.LoadScene />
+      <MainMenu.DefaultItems.SaveToActiveFile />
+      {/* FIXME we should to test for this inside the item itself */}
+      {UIOptions.canvasActions.export && <MainMenu.DefaultItems.Export />}
+      {/* FIXME we should to test for this inside the item itself */}
+      {UIOptions.canvasActions.saveAsImage && (
+        <MainMenu.DefaultItems.SaveAsImage />
+      )}
+      <MainMenu.DefaultItems.Help />
+      <MainMenu.DefaultItems.ClearCanvas />
+      <MainMenu.Separator />
+      <MainMenu.Group title="Excalidraw links">
+        <MainMenu.DefaultItems.Socials />
+      </MainMenu.Group>
+      <MainMenu.Separator />
+      <MainMenu.DefaultItems.ToggleTheme />
+      <MainMenu.DefaultItems.ChangeCanvasBackground />
+    </MainMenu>
+  );
+};
+
 const LayerUI = ({
   actionManager,
   appState,
@@ -93,7 +116,6 @@ const LayerUI = ({
   onPenModeToggle,
   onInsertElements,
   showExitZenModeBtn,
-  isCollaborating,
   renderTopRightUI,
   renderCustomStats,
   renderCustomSidebar,
@@ -108,28 +130,6 @@ const LayerUI = ({
 }: LayerUIProps) => {
   const device = useDevice();
 
-  const [childrenComponents, restChildren] =
-    getReactChildren<UIChildrenComponents>(children, {
-      Menu: true,
-      FooterCenter: true,
-      WelcomeScreen: true,
-    });
-
-  const [WelcomeScreenComponents] = getReactChildren<UIWelcomeScreenComponents>(
-    renderWelcomeScreen
-      ? (
-          childrenComponents?.WelcomeScreen ?? (
-            <WelcomeScreen>
-              <WelcomeScreen.Center />
-              <WelcomeScreen.Hints.MenuHint />
-              <WelcomeScreen.Hints.ToolbarHint />
-              <WelcomeScreen.Hints.HelpHint />
-            </WelcomeScreen>
-          )
-        )?.props?.children
-      : null,
-  );
-
   const renderJSONExportDialog = () => {
     if (!UIOptions.canvasActions.export) {
       return null;
@@ -197,37 +197,12 @@ const LayerUI = ({
     );
   };
 
-  const renderMenu = () => {
-    return (
-      childrenComponents.Menu || (
-        <MainMenu>
-          <MainMenu.DefaultItems.LoadScene />
-          <MainMenu.DefaultItems.SaveToActiveFile />
-          {/* FIXME we should to test for this inside the item itself */}
-          {UIOptions.canvasActions.export && <MainMenu.DefaultItems.Export />}
-          {/* FIXME we should to test for this inside the item itself */}
-          {UIOptions.canvasActions.saveAsImage && (
-            <MainMenu.DefaultItems.SaveAsImage />
-          )}
-          <MainMenu.DefaultItems.Help />
-          <MainMenu.DefaultItems.ClearCanvas />
-          <MainMenu.Separator />
-          <MainMenu.Group title="Excalidraw links">
-            <MainMenu.DefaultItems.Socials />
-          </MainMenu.Group>
-          <MainMenu.Separator />
-          <MainMenu.DefaultItems.ToggleTheme />
-          <MainMenu.DefaultItems.ChangeCanvasBackground />
-        </MainMenu>
-      )
-    );
-  };
   const renderCanvasActions = () => (
     <div style={{ position: "relative" }}>
-      {WelcomeScreenComponents.MenuHint}
       {/* wrapping to Fragment stops React from occasionally complaining
                 about identical Keys */}
-      <>{renderMenu()}</>
+      <mainMenuTunnel.Out />
+      {renderWelcomeScreen && <welcomeScreenMenuHintTunnel.Out />}
     </div>
   );
 
@@ -264,7 +239,6 @@ const LayerUI = ({
 
     return (
       <FixedSideContainer side="top">
-        {WelcomeScreenComponents.Center}
         <div className="App-menu App-menu_top">
           <Stack.Col
             gap={6}
@@ -279,7 +253,9 @@ const LayerUI = ({
             <Section heading="shapes" className="shapes-section">
               {(heading: React.ReactNode) => (
                 <div style={{ position: "relative" }}>
-                  {WelcomeScreenComponents.ToolbarHint}
+                  {renderWelcomeScreen && (
+                    <welcomeScreenToolbarHintTunnel.Out />
+                  )}
                   <Stack.Col gap={4} align="start">
                     <Stack.Row
                       gap={1}
@@ -380,7 +356,16 @@ const LayerUI = ({
 
   return (
     <>
-      {restChildren}
+      {/* ------------------------- tunneled UI ---------------------------- */}
+      {/* make sure we render host app components first so that we can detect
+          them first on initial render to optimize layout shift */}
+      {children}
+      {/* render component fallbacks. Can be rendered anywhere as they'll be
+          tunneled away. We only render tunneled components that actually
+          have defaults when host do not render anything. */}
+      <DefaultMainMenu UIOptions={UIOptions} />
+      {/* ------------------------------------------------------------------ */}
+
       {appState.isLoading && <LoadingMessage delay={250} />}
       {appState.errorMessage && (
         <ErrorDialog
@@ -427,8 +412,6 @@ const LayerUI = ({
           renderCustomStats={renderCustomStats}
           renderSidebars={renderSidebars}
           device={device}
-          renderMenu={renderMenu}
-          welcomeScreenCenter={WelcomeScreenComponents.Center}
         />
       )}
 
@@ -451,13 +434,13 @@ const LayerUI = ({
                 : {}
             }
           >
+            {renderWelcomeScreen && <welcomeScreenCenterTunnel.Out />}
             {renderFixedSideContainer()}
             <Footer
               appState={appState}
               actionManager={actionManager}
               showExitZenModeBtn={showExitZenModeBtn}
-              footerCenter={childrenComponents.FooterCenter}
-              welcomeScreenHelp={WelcomeScreenComponents.HelpHint}
+              renderWelcomeScreen={renderWelcomeScreen}
             />
             {appState.showStats && (
               <Stats

+ 9 - 13
src/components/MobileMenu.tsx

@@ -1,10 +1,5 @@
 import React from "react";
-import {
-  AppState,
-  Device,
-  ExcalidrawProps,
-  UIWelcomeScreenComponents,
-} from "../types";
+import { AppState, Device, ExcalidrawProps } from "../types";
 import { ActionManager } from "../actions/manager";
 import { t } from "../i18n";
 import Stack from "./Stack";
@@ -24,6 +19,7 @@ import { Stats } from "./Stats";
 import { actionToggleStats } from "../actions";
 import { HandButton } from "./HandButton";
 import { isHandToolActive } from "../appState";
+import { mainMenuTunnel, welcomeScreenCenterTunnel } from "./tunnels";
 
 type MobileMenuProps = {
   appState: AppState;
@@ -45,8 +41,6 @@ type MobileMenuProps = {
   renderCustomStats?: ExcalidrawProps["renderCustomStats"];
   renderSidebars: () => JSX.Element | null;
   device: Device;
-  renderMenu: () => React.ReactNode;
-  welcomeScreenCenter: UIWelcomeScreenComponents["Center"];
 };
 
 export const MobileMenu = ({
@@ -63,13 +57,11 @@ export const MobileMenu = ({
   renderCustomStats,
   renderSidebars,
   device,
-  renderMenu,
-  welcomeScreenCenter,
 }: MobileMenuProps) => {
   const renderToolbar = () => {
     return (
       <FixedSideContainer side="top" className="App-top-bar">
-        {welcomeScreenCenter}
+        <welcomeScreenCenterTunnel.Out />
         <Section heading="shapes">
           {(heading: React.ReactNode) => (
             <Stack.Col gap={4} align="center">
@@ -135,12 +127,16 @@ export const MobileMenu = ({
 
   const renderAppToolbar = () => {
     if (appState.viewModeEnabled) {
-      return <div className="App-toolbar-content">{renderMenu()}</div>;
+      return (
+        <div className="App-toolbar-content">
+          <mainMenuTunnel.Out />
+        </div>
+      );
     }
 
     return (
       <div className="App-toolbar-content">
-        {renderMenu()}
+        <mainMenuTunnel.Out />
         {actionManager.renderAction("toggleEditMenu")}
         {actionManager.renderAction("undo")}
         {actionManager.renderAction("redo")}

+ 6 - 11
src/components/footer/Footer.tsx

@@ -1,11 +1,7 @@
 import clsx from "clsx";
 import { actionShortcuts } from "../../actions";
 import { ActionManager } from "../../actions/manager";
-import {
-  AppState,
-  UIChildrenComponents,
-  UIWelcomeScreenComponents,
-} from "../../types";
+import { AppState } from "../../types";
 import {
   ExitZenModeAction,
   FinalizeAction,
@@ -16,19 +12,18 @@ import { useDevice } from "../App";
 import { HelpButton } from "../HelpButton";
 import { Section } from "../Section";
 import Stack from "../Stack";
+import { footerCenterTunnel, welcomeScreenHelpHintTunnel } from "../tunnels";
 
 const Footer = ({
   appState,
   actionManager,
   showExitZenModeBtn,
-  footerCenter,
-  welcomeScreenHelp,
+  renderWelcomeScreen,
 }: {
   appState: AppState;
   actionManager: ActionManager;
   showExitZenModeBtn: boolean;
-  footerCenter: UIChildrenComponents["FooterCenter"];
-  welcomeScreenHelp: UIWelcomeScreenComponents["HelpHint"];
+  renderWelcomeScreen: boolean;
 }) => {
   const device = useDevice();
   const showFinalize =
@@ -73,14 +68,14 @@ const Footer = ({
           </Section>
         </Stack.Col>
       </div>
-      {footerCenter}
+      <footerCenterTunnel.Out />
       <div
         className={clsx("layer-ui__wrapper__footer-right zen-mode-transition", {
           "transition-right disable-pointerEvents": appState.zenModeEnabled,
         })}
       >
         <div style={{ position: "relative" }}>
-          {welcomeScreenHelp}
+          {renderWelcomeScreen && <welcomeScreenHelpHintTunnel.Out />}
           <HelpButton
             onClick={() => actionManager.executeAction(actionShortcuts)}
           />

+ 11 - 8
src/components/footer/FooterCenter.tsx

@@ -1,18 +1,21 @@
 import clsx from "clsx";
 import { useExcalidrawAppState } from "../App";
+import { footerCenterTunnel } from "../tunnels";
 import "./FooterCenter.scss";
 
 const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
   const appState = useExcalidrawAppState();
   return (
-    <div
-      className={clsx("footer-center zen-mode-transition", {
-        "layer-ui__wrapper__footer-left--transition-bottom":
-          appState.zenModeEnabled,
-      })}
-    >
-      {children}
-    </div>
+    <footerCenterTunnel.In>
+      <div
+        className={clsx("footer-center zen-mode-transition", {
+          "layer-ui__wrapper__footer-left--transition-bottom":
+            appState.zenModeEnabled,
+        })}
+      >
+        {children}
+      </div>
+    </footerCenterTunnel.In>
   );
 };
 

+ 50 - 0
src/components/hoc/withInternalFallback.tsx

@@ -0,0 +1,50 @@
+import { atom, useAtom } from "jotai";
+import React, { useLayoutEffect } from "react";
+
+export const withInternalFallback = <P,>(
+  componentName: string,
+  Component: React.FC<P>,
+) => {
+  const counterAtom = atom(0);
+  // flag set on initial render to tell the fallback component to skip the
+  // render until mount counter are initialized. This is because the counter
+  // is initialized in an effect, and thus we could end rendering both
+  // components at the same time until counter is initialized.
+  let preferHost = false;
+
+  const WrapperComponent: React.FC<
+    P & {
+      __fallback?: boolean;
+    }
+  > = (props) => {
+    const [counter, setCounter] = useAtom(counterAtom);
+
+    useLayoutEffect(() => {
+      setCounter((counter) => counter + 1);
+      return () => {
+        setCounter((counter) => counter - 1);
+      };
+    }, [setCounter]);
+
+    if (!props.__fallback) {
+      preferHost = true;
+    }
+
+    // ensure we don't render fallback and host components at the same time
+    if (
+      // either before the counters are initialized
+      (!counter && props.__fallback && preferHost) ||
+      // or after the counters are initialized, and both are rendered
+      // (this is the default when host renders as well)
+      (counter > 1 && props.__fallback)
+    ) {
+      return null;
+    }
+
+    return <Component {...props} />;
+  };
+
+  WrapperComponent.displayName = componentName;
+
+  return WrapperComponent;
+};

+ 65 - 54
src/components/main-menu/MainMenu.tsx

@@ -11,62 +11,73 @@ import * as DefaultItems from "./DefaultItems";
 import { UserList } from "../UserList";
 import { t } from "../../i18n";
 import { HamburgerMenuIcon } from "../icons";
+import { withInternalFallback } from "../hoc/withInternalFallback";
 import { composeEventHandlers } from "../../utils";
+import { mainMenuTunnel } from "../tunnels";
 
-const MainMenu = ({
-  children,
-  onSelect,
-}: {
-  children?: React.ReactNode;
-  /**
-   * Called when any menu item is selected (clicked on).
-   */
-  onSelect?: (event: Event) => void;
-}) => {
-  const device = useDevice();
-  const appState = useExcalidrawAppState();
-  const setAppState = useExcalidrawSetAppState();
-  const onClickOutside = device.isMobile
-    ? undefined
-    : () => setAppState({ openMenu: null });
+const MainMenu = Object.assign(
+  withInternalFallback(
+    "MainMenu",
+    ({
+      children,
+      onSelect,
+    }: {
+      children?: React.ReactNode;
+      /**
+       * Called when any menu item is selected (clicked on).
+       */
+      onSelect?: (event: Event) => void;
+    }) => {
+      const device = useDevice();
+      const appState = useExcalidrawAppState();
+      const setAppState = useExcalidrawSetAppState();
+      const onClickOutside = device.isMobile
+        ? undefined
+        : () => setAppState({ openMenu: null });
 
-  return (
-    <DropdownMenu open={appState.openMenu === "canvas"}>
-      <DropdownMenu.Trigger
-        onToggle={() => {
-          setAppState({
-            openMenu: appState.openMenu === "canvas" ? null : "canvas",
-          });
-        }}
-      >
-        {HamburgerMenuIcon}
-      </DropdownMenu.Trigger>
-      <DropdownMenu.Content
-        onClickOutside={onClickOutside}
-        onSelect={composeEventHandlers(onSelect, () => {
-          setAppState({ openMenu: null });
-        })}
-      >
-        {children}
-        {device.isMobile && appState.collaborators.size > 0 && (
-          <fieldset className="UserList-Wrapper">
-            <legend>{t("labels.collaborators")}</legend>
-            <UserList mobile={true} collaborators={appState.collaborators} />
-          </fieldset>
-        )}
-      </DropdownMenu.Content>
-    </DropdownMenu>
-  );
-};
-
-MainMenu.Trigger = DropdownMenu.Trigger;
-MainMenu.Item = DropdownMenu.Item;
-MainMenu.ItemLink = DropdownMenu.ItemLink;
-MainMenu.ItemCustom = DropdownMenu.ItemCustom;
-MainMenu.Group = DropdownMenu.Group;
-MainMenu.Separator = DropdownMenu.Separator;
-MainMenu.DefaultItems = DefaultItems;
+      return (
+        <mainMenuTunnel.In>
+          <DropdownMenu open={appState.openMenu === "canvas"}>
+            <DropdownMenu.Trigger
+              onToggle={() => {
+                setAppState({
+                  openMenu: appState.openMenu === "canvas" ? null : "canvas",
+                });
+              }}
+            >
+              {HamburgerMenuIcon}
+            </DropdownMenu.Trigger>
+            <DropdownMenu.Content
+              onClickOutside={onClickOutside}
+              onSelect={composeEventHandlers(onSelect, () => {
+                setAppState({ openMenu: null });
+              })}
+            >
+              {children}
+              {device.isMobile && appState.collaborators.size > 0 && (
+                <fieldset className="UserList-Wrapper">
+                  <legend>{t("labels.collaborators")}</legend>
+                  <UserList
+                    mobile={true}
+                    collaborators={appState.collaborators}
+                  />
+                </fieldset>
+              )}
+            </DropdownMenu.Content>
+          </DropdownMenu>
+        </mainMenuTunnel.In>
+      );
+    },
+  ),
+  {
+    Trigger: DropdownMenu.Trigger,
+    Item: DropdownMenu.Item,
+    ItemLink: DropdownMenu.ItemLink,
+    ItemCustom: DropdownMenu.ItemCustom,
+    Group: DropdownMenu.Group,
+    Separator: DropdownMenu.Separator,
+    DefaultItems,
+  },
+);
 
 export default MainMenu;
-
-MainMenu.displayName = "Menu";

+ 8 - 0
src/components/tunnels.ts

@@ -0,0 +1,8 @@
+import tunnel from "tunnel-rat";
+
+export const mainMenuTunnel = tunnel();
+export const welcomeScreenMenuHintTunnel = tunnel();
+export const welcomeScreenToolbarHintTunnel = tunnel();
+export const welcomeScreenHelpHintTunnel = tunnel();
+export const welcomeScreenCenterTunnel = tunnel();
+export const footerCenterTunnel = tunnel();

+ 15 - 12
src/components/welcome-screen/WelcomeScreen.Center.tsx

@@ -7,6 +7,7 @@ import {
   useExcalidrawAppState,
 } from "../App";
 import { ExcalLogo, HelpIcon, LoadIcon, usersIcon } from "../icons";
+import { welcomeScreenCenterTunnel } from "../tunnels";
 
 const WelcomeScreenMenuItemContent = ({
   icon,
@@ -89,18 +90,20 @@ WelcomeScreenMenuItemLink.displayName = "WelcomeScreenMenuItemLink";
 
 const Center = ({ children }: { children?: React.ReactNode }) => {
   return (
-    <div className="welcome-screen-center">
-      {children || (
-        <>
-          <Logo />
-          <Heading>{t("welcomeScreen.defaults.center_heading")}</Heading>
-          <Menu>
-            <MenuItemLoadScene />
-            <MenuItemHelp />
-          </Menu>
-        </>
-      )}
-    </div>
+    <welcomeScreenCenterTunnel.In>
+      <div className="welcome-screen-center">
+        {children || (
+          <>
+            <Logo />
+            <Heading>{t("welcomeScreen.defaults.center_heading")}</Heading>
+            <Menu>
+              <MenuItemLoadScene />
+              <MenuItemHelp />
+            </Menu>
+          </>
+        )}
+      </div>
+    </welcomeScreenCenterTunnel.In>
   );
 };
 Center.displayName = "Center";

+ 25 - 14
src/components/welcome-screen/WelcomeScreen.Hints.tsx

@@ -4,37 +4,48 @@ import {
   WelcomeScreenMenuArrow,
   WelcomeScreenTopToolbarArrow,
 } from "../icons";
+import {
+  welcomeScreenMenuHintTunnel,
+  welcomeScreenToolbarHintTunnel,
+  welcomeScreenHelpHintTunnel,
+} from "../tunnels";
 
 const MenuHint = ({ children }: { children?: React.ReactNode }) => {
   return (
-    <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu">
-      {WelcomeScreenMenuArrow}
-      <div className="welcome-screen-decor-hint__label">
-        {children || t("welcomeScreen.defaults.menuHint")}
+    <welcomeScreenMenuHintTunnel.In>
+      <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu">
+        {WelcomeScreenMenuArrow}
+        <div className="welcome-screen-decor-hint__label">
+          {children || t("welcomeScreen.defaults.menuHint")}
+        </div>
       </div>
-    </div>
+    </welcomeScreenMenuHintTunnel.In>
   );
 };
 MenuHint.displayName = "MenuHint";
 
 const ToolbarHint = ({ children }: { children?: React.ReactNode }) => {
   return (
-    <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar">
-      <div className="welcome-screen-decor-hint__label">
-        {children || t("welcomeScreen.defaults.toolbarHint")}
+    <welcomeScreenToolbarHintTunnel.In>
+      <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar">
+        <div className="welcome-screen-decor-hint__label">
+          {children || t("welcomeScreen.defaults.toolbarHint")}
+        </div>
+        {WelcomeScreenTopToolbarArrow}
       </div>
-      {WelcomeScreenTopToolbarArrow}
-    </div>
+    </welcomeScreenToolbarHintTunnel.In>
   );
 };
 ToolbarHint.displayName = "ToolbarHint";
 
 const HelpHint = ({ children }: { children?: React.ReactNode }) => {
   return (
-    <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help">
-      <div>{children || t("welcomeScreen.defaults.helpHint")}</div>
-      {WelcomeScreenHelpArrow}
-    </div>
+    <welcomeScreenHelpHintTunnel.In>
+      <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help">
+        <div>{children || t("welcomeScreen.defaults.helpHint")}</div>
+        {WelcomeScreenHelpArrow}
+      </div>
+    </welcomeScreenHelpHintTunnel.In>
   );
 };
 HelpHint.displayName = "HelpHint";

+ 14 - 5
src/components/welcome-screen/WelcomeScreen.tsx

@@ -3,12 +3,21 @@ import { MenuHint, ToolbarHint, HelpHint } from "./WelcomeScreen.Hints";
 
 import "./WelcomeScreen.scss";
 
-const WelcomeScreen = (props: { children: React.ReactNode }) => {
-  // NOTE this component is used as a dummy wrapper to retrieve child props
-  // from, and will never be rendered to DOM directly. As such, we can't
-  // do anything here (use hooks and such)
-  return null;
+const WelcomeScreen = (props: { children?: React.ReactNode }) => {
+  return (
+    <>
+      {props.children || (
+        <>
+          <Center />
+          <MenuHint />
+          <ToolbarHint />
+          <HelpHint />
+        </>
+      )}
+    </>
+  );
 };
+
 WelcomeScreen.displayName = "WelcomeScreen";
 
 WelcomeScreen.Center = Center;

+ 0 - 1
src/constants.ts

@@ -159,7 +159,6 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
     toggleTheme: null,
     saveAsImage: true,
   },
-  welcomeScreen: true,
 };
 
 // breakpoints

+ 21 - 0
src/excalidraw-app/components/AppFooter.tsx

@@ -0,0 +1,21 @@
+import React from "react";
+import { Footer } from "../../packages/excalidraw/index";
+import { EncryptedIcon } from "./EncryptedIcon";
+import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
+
+export const AppFooter = React.memo(() => {
+  return (
+    <Footer>
+      <div
+        style={{
+          display: "flex",
+          gap: ".5rem",
+          alignItems: "center",
+        }}
+      >
+        <ExcalidrawPlusAppLink />
+        <EncryptedIcon />
+      </div>
+    </Footer>
+  );
+});

+ 40 - 0
src/excalidraw-app/components/AppMainMenu.tsx

@@ -0,0 +1,40 @@
+import React from "react";
+import { PlusPromoIcon } from "../../components/icons";
+import { MainMenu } from "../../packages/excalidraw/index";
+import { LanguageList } from "./LanguageList";
+
+export const AppMainMenu: React.FC<{
+  setCollabDialogShown: (toggle: boolean) => any;
+  isCollaborating: boolean;
+}> = React.memo((props) => {
+  return (
+    <MainMenu>
+      <MainMenu.DefaultItems.LoadScene />
+      <MainMenu.DefaultItems.SaveToActiveFile />
+      <MainMenu.DefaultItems.Export />
+      <MainMenu.DefaultItems.SaveAsImage />
+      <MainMenu.DefaultItems.LiveCollaborationTrigger
+        isCollaborating={props.isCollaborating}
+        onSelect={() => props.setCollabDialogShown(true)}
+      />
+
+      <MainMenu.DefaultItems.Help />
+      <MainMenu.DefaultItems.ClearCanvas />
+      <MainMenu.Separator />
+      <MainMenu.ItemLink
+        icon={PlusPromoIcon}
+        href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger"
+        className="ExcalidrawPlus"
+      >
+        Excalidraw+
+      </MainMenu.ItemLink>
+      <MainMenu.DefaultItems.Socials />
+      <MainMenu.Separator />
+      <MainMenu.DefaultItems.ToggleTheme />
+      <MainMenu.ItemCustom>
+        <LanguageList style={{ width: "100%" }} />
+      </MainMenu.ItemCustom>
+      <MainMenu.DefaultItems.ChangeCanvasBackground />
+    </MainMenu>
+  );
+});

+ 64 - 0
src/excalidraw-app/components/AppWelcomeScreen.tsx

@@ -0,0 +1,64 @@
+import React from "react";
+import { PlusPromoIcon } from "../../components/icons";
+import { t } from "../../i18n";
+import { WelcomeScreen } from "../../packages/excalidraw/index";
+import { isExcalidrawPlusSignedUser } from "../app_constants";
+
+export const AppWelcomeScreen: React.FC<{
+  setCollabDialogShown: (toggle: boolean) => any;
+}> = React.memo((props) => {
+  let headingContent;
+
+  if (isExcalidrawPlusSignedUser) {
+    headingContent = t("welcomeScreen.app.center_heading_plus")
+      .split(/(Excalidraw\+)/)
+      .map((bit, idx) => {
+        if (bit === "Excalidraw+") {
+          return (
+            <a
+              style={{ pointerEvents: "all" }}
+              href={`${process.env.REACT_APP_PLUS_APP}?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenSignedInUser`}
+              key={idx}
+            >
+              Excalidraw+
+            </a>
+          );
+        }
+        return bit;
+      });
+  } else {
+    headingContent = t("welcomeScreen.app.center_heading");
+  }
+
+  return (
+    <WelcomeScreen>
+      <WelcomeScreen.Hints.MenuHint>
+        {t("welcomeScreen.app.menuHint")}
+      </WelcomeScreen.Hints.MenuHint>
+      <WelcomeScreen.Hints.ToolbarHint />
+      <WelcomeScreen.Hints.HelpHint />
+      <WelcomeScreen.Center>
+        <WelcomeScreen.Center.Logo />
+        <WelcomeScreen.Center.Heading>
+          {headingContent}
+        </WelcomeScreen.Center.Heading>
+        <WelcomeScreen.Center.Menu>
+          <WelcomeScreen.Center.MenuItemLoadScene />
+          <WelcomeScreen.Center.MenuItemHelp />
+          <WelcomeScreen.Center.MenuItemLiveCollaborationTrigger
+            onSelect={() => props.setCollabDialogShown(true)}
+          />
+          {!isExcalidrawPlusSignedUser && (
+            <WelcomeScreen.Center.MenuItemLink
+              href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
+              shortcut={null}
+              icon={PlusPromoIcon}
+            >
+              Try Excalidraw Plus!
+            </WelcomeScreen.Center.MenuItemLink>
+          )}
+        </WelcomeScreen.Center.Menu>
+      </WelcomeScreen.Center>
+    </WelcomeScreen>
+  );
+});

+ 10 - 108
src/excalidraw-app/index.tsx

@@ -1,6 +1,6 @@
 import polyfill from "../polyfill";
 import LanguageDetector from "i18next-browser-languagedetector";
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useRef, useState } from "react";
 import { trackEvent } from "../analytics";
 import { getDefaultAppState } from "../appState";
 import { ErrorDialog } from "../components/ErrorDialog";
@@ -24,10 +24,7 @@ import { t } from "../i18n";
 import {
   Excalidraw,
   defaultLang,
-  Footer,
-  MainMenu,
   LiveCollaborationTrigger,
-  WelcomeScreen,
 } from "../packages/excalidraw/index";
 import {
   AppState,
@@ -47,7 +44,6 @@ import {
 } from "../utils";
 import {
   FIREBASE_STORAGE_PREFIXES,
-  isExcalidrawPlusSignedUser,
   STORAGE_KEYS,
   SYNC_BROWSER_TABS_TIMEOUT,
 } from "./app_constants";
@@ -85,10 +81,9 @@ 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 { ExcalidrawPlusAppLink } from "./components/ExcalidrawPlusAppLink";
-import { LanguageList } from "./components/LanguageList";
-import { PlusPromoIcon } from "../components/icons";
+import { AppMainMenu } from "./components/AppMainMenu";
+import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
+import { AppFooter } from "./components/AppFooter";
 
 polyfill();
 
@@ -604,96 +599,6 @@ const ExcalidrawWrapper = () => {
     localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
   };
 
-  const renderMenu = () => {
-    return (
-      <MainMenu>
-        <MainMenu.DefaultItems.LoadScene />
-        <MainMenu.DefaultItems.SaveToActiveFile />
-        <MainMenu.DefaultItems.Export />
-        <MainMenu.DefaultItems.SaveAsImage />
-        <MainMenu.DefaultItems.LiveCollaborationTrigger
-          isCollaborating={isCollaborating}
-          onSelect={() => setCollabDialogShown(true)}
-        />
-
-        <MainMenu.DefaultItems.Help />
-        <MainMenu.DefaultItems.ClearCanvas />
-        <MainMenu.Separator />
-        <MainMenu.ItemLink
-          icon={PlusPromoIcon}
-          href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger"
-          className="ExcalidrawPlus"
-        >
-          Excalidraw+
-        </MainMenu.ItemLink>
-        <MainMenu.DefaultItems.Socials />
-        <MainMenu.Separator />
-        <MainMenu.DefaultItems.ToggleTheme />
-        <MainMenu.ItemCustom>
-          <LanguageList style={{ width: "100%" }} />
-        </MainMenu.ItemCustom>
-        <MainMenu.DefaultItems.ChangeCanvasBackground />
-      </MainMenu>
-    );
-  };
-
-  const welcomeScreenJSX = useMemo(() => {
-    let headingContent;
-
-    if (isExcalidrawPlusSignedUser) {
-      headingContent = t("welcomeScreen.app.center_heading_plus")
-        .split(/(Excalidraw\+)/)
-        .map((bit, idx) => {
-          if (bit === "Excalidraw+") {
-            return (
-              <a
-                style={{ pointerEvents: "all" }}
-                href={`${process.env.REACT_APP_PLUS_APP}?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenSignedInUser`}
-                key={idx}
-              >
-                Excalidraw+
-              </a>
-            );
-          }
-          return bit;
-        });
-    } else {
-      headingContent = t("welcomeScreen.app.center_heading");
-    }
-
-    return (
-      <WelcomeScreen>
-        <WelcomeScreen.Hints.MenuHint>
-          {t("welcomeScreen.app.menuHint")}
-        </WelcomeScreen.Hints.MenuHint>
-        <WelcomeScreen.Hints.ToolbarHint />
-        <WelcomeScreen.Hints.HelpHint />
-        <WelcomeScreen.Center>
-          <WelcomeScreen.Center.Logo />
-          <WelcomeScreen.Center.Heading>
-            {headingContent}
-          </WelcomeScreen.Center.Heading>
-          <WelcomeScreen.Center.Menu>
-            <WelcomeScreen.Center.MenuItemLoadScene />
-            <WelcomeScreen.Center.MenuItemHelp />
-            <WelcomeScreen.Center.MenuItemLiveCollaborationTrigger
-              onSelect={() => setCollabDialogShown(true)}
-            />
-            {!isExcalidrawPlusSignedUser && (
-              <WelcomeScreen.Center.MenuItemLink
-                href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
-                shortcut={null}
-                icon={PlusPromoIcon}
-              >
-                Try Excalidraw Plus!
-              </WelcomeScreen.Center.MenuItemLink>
-            )}
-          </WelcomeScreen.Center.Menu>
-        </WelcomeScreen.Center>
-      </WelcomeScreen>
-    );
-  }, [setCollabDialogShown]);
-
   return (
     <div
       style={{ height: "100%" }}
@@ -750,15 +655,12 @@ const ExcalidrawWrapper = () => {
           );
         }}
       >
-        {renderMenu()}
-
-        <Footer>
-          <div style={{ display: "flex", gap: ".5rem", alignItems: "center" }}>
-            <ExcalidrawPlusAppLink />
-            <EncryptedIcon />
-          </div>
-        </Footer>
-        {welcomeScreenJSX}
+        <AppMainMenu
+          setCollabDialogShown={setCollabDialogShown}
+          isCollaborating={isCollaborating}
+        />
+        <AppWelcomeScreen setCollabDialogShown={setCollabDialogShown} />
+        <AppFooter />
       </Excalidraw>
       {excalidrawAPI && <Collab excalidrawAPI={excalidrawAPI} />}
       {errorMessage && (

+ 5 - 0
src/packages/excalidraw/CHANGELOG.md

@@ -15,8 +15,13 @@ Please add the latest change on the top under the correct section.
 
 ### Features
 
+- Welcome screen no longer renders by default, and you need to render it yourself. `UIOptions.welcomeScreen` option is now deprecated. [#6117](https://github.com/excalidraw/excalidraw/pull/6117)
 - `MainMenu`, `MainMenu.Item`, and `MainMenu.ItemLink` components now all support `onSelect(event: Event): void` callback. If you call `event.preventDefault()`, it will prevent the menu from closing when an item is selected (clicked on). [#6152](https://github.com/excalidraw/excalidraw/pull/6152)
 
+### Fixes
+
+- declare css variable for font in excalidraw so its available in host [#6160](https://github.com/excalidraw/excalidraw/pull/6160)
+
 ## 0.14.1 (2023-01-16)
 
 ### Fixes

+ 0 - 1
src/packages/excalidraw/index.tsx

@@ -53,7 +53,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
       ...DEFAULT_UI_OPTIONS.canvasActions,
       ...canvasActions,
     },
-    welcomeScreen: props.UIOptions?.welcomeScreen ?? true,
   };
 
   if (canvasActions?.export) {

+ 3 - 32
src/types.ts

@@ -385,15 +385,16 @@ type CanvasActions = Partial<{
 
 type UIOptions = Partial<{
   dockedSidebarBreakpoint: number;
-  welcomeScreen: boolean;
   canvasActions: CanvasActions;
+  /** @deprecated does nothing. Will be removed in 0.15 */
+  welcomeScreen?: boolean;
 }>;
 
 export type AppProps = Merge<
   ExcalidrawProps,
   {
     UIOptions: Merge<
-      MarkRequired<UIOptions, "welcomeScreen">,
+      UIOptions,
       {
         canvasActions: Required<CanvasActions> & { export: ExportOpts };
       }
@@ -523,33 +524,3 @@ export type Device = Readonly<{
   isTouchScreen: boolean;
   canDeviceFitSidebar: boolean;
 }>;
-
-export type UIChildrenComponents = {
-  [k in "FooterCenter" | "Menu" | "WelcomeScreen"]?: React.ReactElement<
-    { children?: React.ReactNode },
-    React.JSXElementConstructor<any>
-  >;
-};
-
-export type UIWelcomeScreenComponents = {
-  [k in
-    | "Center"
-    | "MenuHint"
-    | "ToolbarHint"
-    | "HelpHint"]?: React.ReactElement<
-    { children?: React.ReactNode },
-    React.JSXElementConstructor<any>
-  >;
-};
-
-export type UIWelcomeScreenCenterComponents = {
-  [k in
-    | "Logo"
-    | "Heading"
-    | "Menu"
-    | "MenuItemLoadScene"
-    | "MenuItemHelp"]?: React.ReactElement<
-    { children?: React.ReactNode },
-    React.JSXElementConstructor<any>
-  >;
-};

+ 0 - 43
src/utils.ts

@@ -15,7 +15,6 @@ import { FontFamilyValues, FontString } from "./element/types";
 import { AppState, DataURL, LastActiveTool, Zoom } from "./types";
 import { unstable_batchedUpdates } from "react-dom";
 import { SHAPES } from "./shapes";
-import React from "react";
 import { isEraserActive, isHandToolActive } from "./appState";
 
 let mockDateTime: string | null = null;
@@ -690,48 +689,6 @@ export const queryFocusableElements = (container: HTMLElement | null) => {
     : [];
 };
 
-/**
- * Partitions React children into named components and the rest of children.
- *
- * Returns known children as a dictionary of react children keyed by their
- * displayName, and the rest children as an array.
- *
- * NOTE all named react components are included in the dictionary, irrespective
- * of the supplied type parameter. This means you may be throwing away
- * children that you aren't expecting, but should nonetheless be rendered.
- * To guard against this (provided you care about the rest children at all),
- * supply a second parameter with an object with keys of the expected children.
- */
-export const getReactChildren = <
-  KnownChildren extends {
-    [k in string]?: React.ReactNode;
-  },
->(
-  children: React.ReactNode,
-  expectedComponents?: Record<keyof KnownChildren, any>,
-) => {
-  const restChildren: React.ReactNode[] = [];
-
-  const knownChildren = React.Children.toArray(children).reduce(
-    (acc, child) => {
-      if (
-        React.isValidElement(child) &&
-        (!expectedComponents ||
-          ((child.type as any).displayName as string) in expectedComponents)
-      ) {
-        // @ts-ignore
-        acc[child.type.displayName] = child;
-      } else {
-        restChildren.push(child);
-      }
-      return acc;
-    },
-    {} as Partial<KnownChildren>,
-  );
-
-  return [knownChildren, restChildren] as const;
-};
-
 export const isShallowEqual = <T extends Record<string, any>>(
   objA: T,
   objB: T,

+ 19 - 0
yarn.lock

@@ -10238,6 +10238,13 @@ tsutils@^3.21.0:
   dependencies:
     tslib "^1.8.1"
 
+tunnel-rat@0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/tunnel-rat/-/tunnel-rat-0.1.0.tgz#62cfbaf1b24cabac9318fe45ef26d70dc40e86fe"
+  integrity sha512-/FKZLBXCoKhA7Wz+dsqitrItaLXYmT2bkZXod+1UuR4JqHtdb54yHvHhmMgLg+eyH1Od/CCnhA2VQQ2A/54Tcw==
+  dependencies:
+    zustand "^4.1.0"
+
 type-check@^0.4.0, type-check@~0.4.0:
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
@@ -10406,6 +10413,11 @@ url-parse@^1.5.3:
     querystringify "^2.1.1"
     requires-port "^1.0.0"
 
+use-sync-external-store@1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
+  integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
+
 util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
@@ -11015,3 +11027,10 @@ yocto-queue@^0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
   integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
+
+zustand@^4.1.0:
+  version "4.3.2"
+  resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.3.2.tgz#bb121fcad84c5a569e94bd1a2695e1a93ba85d39"
+  integrity sha512-rd4haDmlwMTVWVqwvgy00ny8rtti/klRoZjFbL/MAcDnmD5qSw/RZc+Vddstdv90M5Lv6RPgWvm1Hivyn0QgJw==
+  dependencies:
+    use-sync-external-store "1.2.0"