Bläddra i källkod

Merge remote-tracking branch 'origin/master' into feat-custom-actions

Daniel J. Geiger 2 år sedan
förälder
incheckning
e385066b4b
47 ändrade filer med 1872 tillägg och 1138 borttagningar
  1. 1 1
      .husky/pre-commit
  2. 9 7
      src/components/App.tsx
  3. 7 0
      src/components/Button.scss
  4. 35 0
      src/components/Button.tsx
  5. 0 32
      src/components/CollabButton.tsx
  6. 6 4
      src/components/FixedSideContainer.scss
  7. 64 70
      src/components/LayerUI.tsx
  8. 3 3
      src/components/LibraryMenuHeaderContent.tsx
  9. 9 25
      src/components/MobileMenu.tsx
  10. 0 121
      src/components/WelcomeScreen.tsx
  11. 0 11
      src/components/WelcomeScreenDecor.tsx
  12. 1 1
      src/components/dropdownMenu/DropdownMenu.scss
  13. 4 11
      src/components/dropdownMenu/DropdownMenuItem.tsx
  14. 5 7
      src/components/dropdownMenu/DropdownMenuItemCustom.tsx
  15. 4 11
      src/components/dropdownMenu/DropdownMenuItemLink.tsx
  16. 8 16
      src/components/footer/Footer.tsx
  17. 1 1
      src/components/icons.tsx
  18. 13 20
      src/components/live-collaboration/LiveCollaborationTrigger.scss
  19. 40 0
      src/components/live-collaboration/LiveCollaborationTrigger.tsx
  20. 0 0
      src/components/main-menu/DefaultItems.scss
  21. 23 23
      src/components/main-menu/DefaultItems.tsx
  22. 0 0
      src/components/main-menu/MainMenu.tsx
  23. 195 0
      src/components/welcome-screen/WelcomeScreen.Center.tsx
  24. 42 0
      src/components/welcome-screen/WelcomeScreen.Hints.tsx
  25. 88 77
      src/components/welcome-screen/WelcomeScreen.scss
  26. 17 0
      src/components/welcome-screen/WelcomeScreen.tsx
  27. 1 8
      src/constants.ts
  28. 4 4
      src/css/styles.scss
  29. 3 2
      src/css/theme.scss
  30. 15 11
      src/css/variables.module.scss
  31. 4 4
      src/element/resizeElements.ts
  32. 8 0
      src/excalidraw-app/app_constants.ts
  33. 1 1
      src/excalidraw-app/components/ExcalidrawPlusAppLink.tsx
  34. 75 3
      src/excalidraw-app/index.tsx
  35. 11 5
      src/locales/en.json
  36. 177 4
      src/packages/excalidraw/CHANGELOG.md
  37. 208 62
      src/packages/excalidraw/README.md
  38. 28 21
      src/packages/excalidraw/example/App.tsx
  39. 10 0
      src/packages/excalidraw/example/CustomFooter.tsx
  40. 16 12
      src/packages/excalidraw/index.tsx
  41. 1 1
      src/packages/excalidraw/package.json
  42. BIN
      src/packages/excalidraw/welcome-screen-overview.png
  43. 27 23
      src/renderer/renderElement.ts
  44. 2 2
      src/scene/scroll.ts
  45. 607 493
      src/tests/flip.test.tsx
  46. 49 21
      src/types.ts
  47. 50 20
      src/utils.ts

+ 1 - 1
.husky/pre-commit

@@ -1,2 +1,2 @@
 #!/bin/sh
-yarn lint-staged
+# yarn lint-staged

+ 9 - 7
src/components/App.tsx

@@ -283,15 +283,12 @@ const deviceContextInitialValue = {
 };
 const DeviceContext = React.createContext<Device>(deviceContextInitialValue);
 DeviceContext.displayName = "DeviceContext";
-export const useDevice = () => useContext<Device>(DeviceContext);
 
 export const ExcalidrawContainerContext = React.createContext<{
   container: HTMLDivElement | null;
   id: string | null;
 }>({ container: null, id: null });
 ExcalidrawContainerContext.displayName = "ExcalidrawContainerContext";
-export const useExcalidrawContainer = () =>
-  useContext(ExcalidrawContainerContext);
 
 const ExcalidrawElementsContext = React.createContext<
   readonly NonDeletedExcalidrawElement[]
@@ -309,7 +306,9 @@ ExcalidrawAppStateContext.displayName = "ExcalidrawAppStateContext";
 
 const ExcalidrawSetAppStateContext = React.createContext<
   React.Component<any, AppState>["setState"]
->(() => {});
+>(() => {
+  console.warn("unitialized ExcalidrawSetAppStateContext context!");
+});
 ExcalidrawSetAppStateContext.displayName = "ExcalidrawSetAppStateContext";
 
 const ExcalidrawActionManagerContext = React.createContext<ActionManager>(
@@ -317,6 +316,9 @@ const ExcalidrawActionManagerContext = React.createContext<ActionManager>(
 );
 ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext";
 
+export const useDevice = () => useContext<Device>(DeviceContext);
+export const useExcalidrawContainer = () =>
+  useContext(ExcalidrawContainerContext);
 export const useExcalidrawElements = () =>
   useContext(ExcalidrawElementsContext);
 export const useExcalidrawAppState = () =>
@@ -539,8 +541,7 @@ class App extends React.Component<AppProps, AppState> {
       this.scene.getNonDeletedElements(),
       this.state,
     );
-    const { onCollabButtonClick, renderTopRightUI, renderCustomStats } =
-      this.props;
+    const { renderTopRightUI, renderCustomStats } = this.props;
 
     return (
       <div
@@ -574,7 +575,6 @@ class App extends React.Component<AppProps, AppState> {
                       setAppState={this.setAppState}
                       actionManager={this.actionManager}
                       elements={this.scene.getNonDeletedElements()}
-                      onCollabButtonClick={onCollabButtonClick}
                       onLockToggle={this.toggleLock}
                       onPenModeToggle={this.togglePenMode}
                       onInsertElements={(elements) =>
@@ -601,6 +601,8 @@ class App extends React.Component<AppProps, AppState> {
                       id={this.id}
                       onImageAction={this.onImageAction}
                       renderWelcomeScreen={
+                        !this.state.isLoading &&
+                        this.props.UIOptions.welcomeScreen &&
                         this.state.showWelcomeScreen &&
                         this.state.activeTool.type === "selection" &&
                         !this.scene.getElementsIncludingDeleted().length

+ 7 - 0
src/components/Button.scss

@@ -0,0 +1,7 @@
+@import "../css/theme";
+
+.excalidraw {
+  .excalidraw-button {
+    @include outlineButtonStyles;
+  }
+}

+ 35 - 0
src/components/Button.tsx

@@ -0,0 +1,35 @@
+import "./Button.scss";
+
+interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
+  type?: "button" | "submit" | "reset";
+  onSelect: () => any;
+  children: React.ReactNode;
+  className?: string;
+}
+
+/**
+ * A generic button component that follows Excalidraw's design system.
+ * Style can be customised using `className` or `style` prop.
+ * Accepts all props that a regular `button` element accepts.
+ */
+export const Button = ({
+  type = "button",
+  onSelect,
+  children,
+  className = "",
+  ...rest
+}: ButtonProps) => {
+  return (
+    <button
+      onClick={(event) => {
+        onSelect();
+        rest.onClick?.(event);
+      }}
+      type={type}
+      className={`excalidraw-button ${className}`}
+      {...rest}
+    >
+      {children}
+    </button>
+  );
+};

+ 0 - 32
src/components/CollabButton.tsx

@@ -1,32 +0,0 @@
-import { t } from "../i18n";
-import { UsersIcon } from "./icons";
-
-import "./CollabButton.scss";
-import clsx from "clsx";
-
-const CollabButton = ({
-  isCollaborating,
-  collaboratorCount,
-  onClick,
-}: {
-  isCollaborating: boolean;
-  collaboratorCount: number;
-  onClick: () => void;
-}) => {
-  return (
-    <button
-      className={clsx("collab-button", { active: isCollaborating })}
-      type="button"
-      onClick={onClick}
-      style={{ position: "relative" }}
-      title={t("labels.liveCollaboration")}
-    >
-      {UsersIcon}
-      {collaboratorCount > 0 && (
-        <div className="CollabButton-collaborators">{collaboratorCount}</div>
-      )}
-    </button>
-  );
-};
-
-export default CollabButton;

+ 6 - 4
src/components/FixedSideContainer.scss

@@ -1,3 +1,5 @@
+@import "../css/variables.module";
+
 .excalidraw {
   .FixedSideContainer {
     position: absolute;
@@ -9,10 +11,10 @@
   }
 
   .FixedSideContainer_side_top {
-    left: 1rem;
-    top: 1rem;
-    right: 1rem;
-    bottom: 1rem;
+    left: var(--editor-container-padding);
+    top: var(--editor-container-padding);
+    right: var(--editor-container-padding);
+    bottom: var(--editor-container-padding);
     z-index: 2;
   }
 

+ 64 - 70
src/components/LayerUI.tsx

@@ -14,10 +14,10 @@ import {
   ExcalidrawProps,
   BinaryFiles,
   UIChildrenComponents,
+  UIWelcomeScreenComponents,
 } from "../types";
-import { muteFSAbortError, ReactChildrenToObject } from "../utils";
+import { isShallowEqual, muteFSAbortError, getReactChildren } from "../utils";
 import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
-import CollabButton from "./CollabButton";
 import { ErrorDialog } from "./ErrorDialog";
 import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
 import { FixedSideContainer } from "./FixedSideContainer";
@@ -45,13 +45,11 @@ import { useDevice } from "../components/App";
 import { Stats } from "./Stats";
 import { actionToggleStats } from "../actions/actionToggleStats";
 import Footer from "./footer/Footer";
-import { WelcomeScreenMenuArrow, WelcomeScreenTopToolbarArrow } from "./icons";
-import WelcomeScreen from "./WelcomeScreen";
+import WelcomeScreen from "./welcome-screen/WelcomeScreen";
 import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
 import { jotaiScope } from "../jotai";
 import { useAtom } from "jotai";
-import WelcomeScreenDecor from "./WelcomeScreenDecor";
-import MainMenu from "./mainMenu/MainMenu";
+import MainMenu from "./main-menu/MainMenu";
 
 interface LayerUIProps {
   actionManager: ActionManager;
@@ -60,7 +58,6 @@ interface LayerUIProps {
   canvas: HTMLCanvasElement | null;
   setAppState: React.Component<any, AppState>["setState"];
   elements: readonly NonDeletedExcalidrawElement[];
-  onCollabButtonClick?: () => void;
   onLockToggle: () => void;
   onPenModeToggle: () => void;
   onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
@@ -88,7 +85,6 @@ const LayerUI = ({
   setAppState,
   elements,
   canvas,
-  onCollabButtonClick,
   onLockToggle,
   onPenModeToggle,
   onInsertElements,
@@ -109,8 +105,27 @@ const LayerUI = ({
 }: LayerUIProps) => {
   const device = useDevice();
 
-  const childrenComponents =
-    ReactChildrenToObject<UIChildrenComponents>(children);
+  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) {
@@ -191,12 +206,6 @@ const LayerUI = ({
           {UIOptions.canvasActions.saveAsImage && (
             <MainMenu.DefaultItems.SaveAsImage />
           )}
-          {onCollabButtonClick && (
-            <MainMenu.DefaultItems.LiveCollaboration
-              onSelect={onCollabButtonClick}
-              isCollaborating={isCollaborating}
-            />
-          )}
           <MainMenu.DefaultItems.Help />
           <MainMenu.DefaultItems.ClearCanvas />
           <MainMenu.Separator />
@@ -212,15 +221,10 @@ const LayerUI = ({
   };
   const renderCanvasActions = () => (
     <div style={{ position: "relative" }}>
-      <WelcomeScreenDecor
-        shouldRender={renderWelcomeScreen && !appState.isLoading}
-      >
-        <div className="virgil WelcomeScreen-decor WelcomeScreen-decor--menu-pointer">
-          {WelcomeScreenMenuArrow}
-          <div>{t("welcomeScreen.menuHints")}</div>
-        </div>
-      </WelcomeScreenDecor>
-      {renderMenu()}
+      {WelcomeScreenComponents.MenuHint}
+      {/* wrapping to Fragment stops React from occasionally complaining
+                about identical Keys */}
+      <>{renderMenu()}</>
     </div>
   );
 
@@ -257,9 +261,7 @@ const LayerUI = ({
 
     return (
       <FixedSideContainer side="top">
-        {renderWelcomeScreen && !appState.isLoading && (
-          <WelcomeScreen appState={appState} actionManager={actionManager} />
-        )}
+        {WelcomeScreenComponents.Center}
         <div className="App-menu App-menu_top">
           <Stack.Col
             gap={6}
@@ -274,17 +276,7 @@ const LayerUI = ({
             <Section heading="shapes" className="shapes-section">
               {(heading: React.ReactNode) => (
                 <div style={{ position: "relative" }}>
-                  <WelcomeScreenDecor
-                    shouldRender={renderWelcomeScreen && !appState.isLoading}
-                  >
-                    <div className="virgil WelcomeScreen-decor WelcomeScreen-decor--top-toolbar-pointer">
-                      <div className="WelcomeScreen-decor--top-toolbar-pointer__label">
-                        {t("welcomeScreen.toolbarHints")}
-                      </div>
-                      {WelcomeScreenTopToolbarArrow}
-                    </div>
-                  </WelcomeScreenDecor>
-
+                  {WelcomeScreenComponents.ToolbarHint}
                   <Stack.Col gap={4} align="start">
                     <Stack.Row
                       gap={1}
@@ -353,13 +345,6 @@ const LayerUI = ({
             )}
           >
             <UserList collaborators={appState.collaborators} />
-            {onCollabButtonClick && (
-              <CollabButton
-                isCollaborating={isCollaborating}
-                collaboratorCount={appState.collaborators.size}
-                onClick={onCollabButtonClick}
-              />
-            )}
             {renderTopRightUI?.(device.isMobile, appState)}
             {!appState.viewModeEnabled && (
               <LibraryButton appState={appState} setAppState={setAppState} />
@@ -389,6 +374,7 @@ const LayerUI = ({
 
   return (
     <>
+      {restChildren}
       {appState.isLoading && <LoadingMessage delay={250} />}
       {appState.errorMessage && (
         <ErrorDialog
@@ -419,18 +405,15 @@ const LayerUI = ({
       )}
       {device.isMobile && (
         <MobileMenu
-          renderWelcomeScreen={renderWelcomeScreen}
           appState={appState}
           elements={elements}
           actionManager={actionManager}
           renderJSONExportDialog={renderJSONExportDialog}
           renderImageExportDialog={renderImageExportDialog}
           setAppState={setAppState}
-          onCollabButtonClick={onCollabButtonClick}
           onLockToggle={() => onLockToggle()}
           onPenModeToggle={onPenModeToggle}
           canvas={canvas}
-          isCollaborating={isCollaborating}
           onImageAction={onImageAction}
           renderTopRightUI={renderTopRightUI}
           renderCustomStats={renderCustomStats}
@@ -438,6 +421,7 @@ const LayerUI = ({
           device={device}
           renderMenu={renderMenu}
           onContextMenu={onContextMenu}
+          welcomeScreenCenter={WelcomeScreenComponents.Center}
         />
       )}
 
@@ -462,13 +446,12 @@ const LayerUI = ({
           >
             {renderFixedSideContainer()}
             <Footer
-              renderWelcomeScreen={renderWelcomeScreen}
               appState={appState}
               actionManager={actionManager}
               showExitZenModeBtn={showExitZenModeBtn}
               footerCenter={childrenComponents.FooterCenter}
+              welcomeScreenHelp={WelcomeScreenComponents.HelpHint}
             />
-
             {appState.showStats && (
               <Stats
                 appState={appState}
@@ -500,28 +483,39 @@ const LayerUI = ({
   );
 };
 
-const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
-  const getNecessaryObj = (appState: AppState): Partial<AppState> => {
-    const {
-      suggestedBindings,
-      startBoundElement: boundElement,
-      ...ret
-    } = appState;
-    return ret;
-  };
-  const prevAppState = getNecessaryObj(prev.appState);
-  const nextAppState = getNecessaryObj(next.appState);
+const stripIrrelevantAppStateProps = (
+  appState: AppState,
+): Partial<AppState> => {
+  const { suggestedBindings, startBoundElement, cursorButton, ...ret } =
+    appState;
+  return ret;
+};
+
+const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
+  // short-circuit early
+  if (prevProps.children !== nextProps.children) {
+    return false;
+  }
 
-  const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
+  const {
+    canvas: _prevCanvas,
+    // not stable, but shouldn't matter in our case
+    onInsertElements: _prevOnInsertElements,
+    appState: prevAppState,
+    ...prev
+  } = prevProps;
+  const {
+    canvas: _nextCanvas,
+    onInsertElements: _nextOnInsertElements,
+    appState: nextAppState,
+    ...next
+  } = nextProps;
 
   return (
-    prev.renderTopRightUI === next.renderTopRightUI &&
-    prev.renderCustomStats === next.renderCustomStats &&
-    prev.renderCustomSidebar === next.renderCustomSidebar &&
-    prev.langCode === next.langCode &&
-    prev.elements === next.elements &&
-    prev.files === next.files &&
-    keys.every((key) => prevAppState[key] === nextAppState[key])
+    isShallowEqual(
+      stripIrrelevantAppStateProps(prevAppState),
+      stripIrrelevantAppStateProps(nextAppState),
+    ) && isShallowEqual(prev, next)
   );
 };
 

+ 3 - 3
src/components/LibraryMenuHeaderContent.tsx

@@ -193,7 +193,7 @@ export const LibraryMenuHeader: React.FC<{
             <DropdownMenu.Item
               onSelect={onLibraryImport}
               icon={LoadIcon}
-              dataTestId="lib-dropdown--load"
+              data-testid="lib-dropdown--load"
             >
               {t("buttons.load")}
             </DropdownMenu.Item>
@@ -202,7 +202,7 @@ export const LibraryMenuHeader: React.FC<{
             <DropdownMenu.Item
               onSelect={onLibraryExport}
               icon={ExportIcon}
-              dataTestId="lib-dropdown--export"
+              data-testid="lib-dropdown--export"
             >
               {t("buttons.export")}
             </DropdownMenu.Item>
@@ -219,7 +219,7 @@ export const LibraryMenuHeader: React.FC<{
             <DropdownMenu.Item
               icon={publishIcon}
               onSelect={() => setShowPublishLibraryDialog(true)}
-              dataTestId="lib-dropdown--remove"
+              data-testid="lib-dropdown--remove"
             >
               {t("buttons.publishLibrary")}
             </DropdownMenu.Item>

+ 9 - 25
src/components/MobileMenu.tsx

@@ -1,5 +1,10 @@
 import React from "react";
-import { AppState, Device, ExcalidrawProps } from "../types";
+import {
+  AppState,
+  Device,
+  ExcalidrawProps,
+  UIWelcomeScreenComponents,
+} from "../types";
 import { ActionManager } from "../actions/manager";
 import { t } from "../i18n";
 import Stack from "./Stack";
@@ -17,7 +22,6 @@ import { LibraryButton } from "./LibraryButton";
 import { PenModeButton } from "./PenModeButton";
 import { Stats } from "./Stats";
 import { actionToggleStats } from "../actions";
-import WelcomeScreen from "./WelcomeScreen";
 
 type MobileMenuProps = {
   appState: AppState;
@@ -26,11 +30,9 @@ type MobileMenuProps = {
   renderImageExportDialog: () => React.ReactNode;
   setAppState: React.Component<any, AppState>["setState"];
   elements: readonly NonDeletedExcalidrawElement[];
-  onCollabButtonClick?: () => void;
   onLockToggle: () => void;
   onPenModeToggle: () => void;
   canvas: HTMLCanvasElement | null;
-  isCollaborating: boolean;
 
   onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
   renderTopRightUI?: (
@@ -40,9 +42,9 @@ type MobileMenuProps = {
   renderCustomStats?: ExcalidrawProps["renderCustomStats"];
   renderSidebars: () => JSX.Element | null;
   device: Device;
-  renderWelcomeScreen?: boolean;
   renderMenu: () => React.ReactNode;
   onContextMenu?: (event: React.MouseEvent, source: string) => void;
+  welcomeScreenCenter: UIWelcomeScreenComponents["Center"];
 };
 
 export const MobileMenu = ({
@@ -53,22 +55,19 @@ export const MobileMenu = ({
   onLockToggle,
   onPenModeToggle,
   canvas,
-  isCollaborating,
   onImageAction,
   renderTopRightUI,
   renderCustomStats,
   renderSidebars,
   device,
-  renderWelcomeScreen,
   renderMenu,
   onContextMenu,
+  welcomeScreenCenter,
 }: MobileMenuProps) => {
   const renderToolbar = () => {
     return (
       <FixedSideContainer side="top" className="App-top-bar">
-        {renderWelcomeScreen && !appState.isLoading && (
-          <WelcomeScreen appState={appState} actionManager={actionManager} />
-        )}
+        {welcomeScreenCenter}
         <Section heading="shapes">
           {(heading: React.ReactNode) => (
             <Stack.Col gap={4} align="center">
@@ -76,20 +75,6 @@ export const MobileMenu = ({
                 <Island padding={1} className="App-toolbar App-toolbar--mobile">
                   {heading}
                   <Stack.Row gap={1}>
-                    {/* <PenModeButton
-                      checked={appState.penMode}
-                      onChange={onPenModeToggle}
-                      title={t("toolBar.penMode")}
-                      isMobile
-                      penDetected={appState.penDetected}
-                    />
-                    <LockButton
-                      checked={appState.activeTool.locked}
-                      onChange={onLockToggle}
-                      title={t("toolBar.lock")}
-                      isMobile
-                    />
-                    <div className="App-toolbar__divider"></div> */}
                     <ShapesSwitcher
                       appState={appState}
                       canvas={canvas}
@@ -112,7 +97,6 @@ export const MobileMenu = ({
                     title={t("toolBar.penMode")}
                     isMobile
                     penDetected={appState.penDetected}
-                    // penDetected={true}
                   />
                   <LockButton
                     checked={appState.activeTool.locked}

+ 0 - 121
src/components/WelcomeScreen.tsx

@@ -1,121 +0,0 @@
-import { actionLoadScene, actionShortcuts } from "../actions";
-import { ActionManager } from "../actions/manager";
-import { getShortcutFromShortcutName } from "../actions/shortcuts";
-import { isExcalidrawPlusSignedUser } from "../constants";
-import { t } from "../i18n";
-import { AppState } from "../types";
-import { ExcalLogo, HelpIcon, LoadIcon, PlusPromoIcon } from "./icons";
-import "./WelcomeScreen.scss";
-
-const WelcomeScreenItem = ({
-  label,
-  shortcut,
-  onClick,
-  icon,
-  link,
-}: {
-  label: string;
-  shortcut: string | null;
-  onClick?: () => void;
-  icon: JSX.Element;
-  link?: string;
-}) => {
-  if (link) {
-    return (
-      <a
-        className="WelcomeScreen-item"
-        href={link}
-        target="_blank"
-        rel="noreferrer"
-      >
-        <div className="WelcomeScreen-item__label">
-          {icon}
-          {label}
-        </div>
-      </a>
-    );
-  }
-
-  return (
-    <button className="WelcomeScreen-item" type="button" onClick={onClick}>
-      <div className="WelcomeScreen-item__label">
-        {icon}
-        {label}
-      </div>
-      {shortcut && (
-        <div className="WelcomeScreen-item__shortcut">{shortcut}</div>
-      )}
-    </button>
-  );
-};
-
-const WelcomeScreen = ({
-  appState,
-  actionManager,
-}: {
-  appState: AppState;
-  actionManager: ActionManager;
-}) => {
-  let subheadingJSX;
-
-  if (isExcalidrawPlusSignedUser) {
-    subheadingJSX = t("welcomeScreen.switchToPlusApp")
-      .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 {
-    subheadingJSX = t("welcomeScreen.data");
-  }
-
-  return (
-    <div className="WelcomeScreen-container">
-      <div className="WelcomeScreen-logo virgil WelcomeScreen-decor">
-        {ExcalLogo} Excalidraw
-      </div>
-      <div className="virgil WelcomeScreen-decor WelcomeScreen-decor--subheading">
-        {subheadingJSX}
-      </div>
-      <div className="WelcomeScreen-items">
-        {!appState.viewModeEnabled && (
-          <WelcomeScreenItem
-            // TODO barnabasmolnar/editor-redesign
-            // do we want the internationalized labels here that are currently
-            // in use elsewhere or new ones?
-            label={t("buttons.load")}
-            onClick={() => actionManager.executeAction(actionLoadScene)}
-            shortcut={getShortcutFromShortcutName("loadScene")}
-            icon={LoadIcon}
-          />
-        )}
-        <WelcomeScreenItem
-          onClick={() => actionManager.executeAction(actionShortcuts)}
-          label={t("helpDialog.title")}
-          shortcut="?"
-          icon={HelpIcon}
-        />
-        {!isExcalidrawPlusSignedUser && (
-          <WelcomeScreenItem
-            link="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
-            label="Try Excalidraw Plus!"
-            shortcut={null}
-            icon={PlusPromoIcon}
-          />
-        )}
-      </div>
-    </div>
-  );
-};
-
-export default WelcomeScreen;

+ 0 - 11
src/components/WelcomeScreenDecor.tsx

@@ -1,11 +0,0 @@
-import { ReactNode } from "react";
-
-const WelcomeScreenDecor = ({
-  children,
-  shouldRender,
-}: {
-  children: ReactNode;
-  shouldRender: boolean;
-}) => (shouldRender ? <>{children}</> : null);
-
-export default WelcomeScreenDecor;

+ 1 - 1
src/components/dropdownMenu/DropdownMenu.scss

@@ -73,7 +73,7 @@
       }
 
       &:hover {
-        background-color: var(--button-hover);
+        background-color: var(--button-hover-bg);
         text-decoration: none;
       }
 

+ 4 - 11
src/components/dropdownMenu/DropdownMenuItem.tsx

@@ -9,30 +9,23 @@ const DropdownMenuItem = ({
   icon,
   onSelect,
   children,
-  dataTestId,
   shortcut,
   className,
-  style,
-  ariaLabel,
+  ...rest
 }: {
   icon?: JSX.Element;
   onSelect: () => void;
   children: React.ReactNode;
-  dataTestId?: string;
   shortcut?: string;
   className?: string;
-  style?: React.CSSProperties;
-  ariaLabel?: string;
-}) => {
+} & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
   return (
     <button
-      aria-label={ariaLabel}
+      {...rest}
       onClick={onSelect}
-      data-testid={dataTestId}
-      title={ariaLabel}
       type="button"
       className={getDrodownMenuItemClassName(className)}
-      style={style}
+      title={rest.title ?? rest["aria-label"]}
     >
       <MenuItemContent icon={icon} shortcut={shortcut}>
         {children}

+ 5 - 7
src/components/dropdownMenu/DropdownMenuItemCustom.tsx

@@ -1,19 +1,17 @@
+import React from "react";
+
 const DropdownMenuItemCustom = ({
   children,
   className = "",
-  style,
-  dataTestId,
+  ...rest
 }: {
   children: React.ReactNode;
   className?: string;
-  style?: React.CSSProperties;
-  dataTestId?: string;
-}) => {
+} & React.HTMLAttributes<HTMLDivElement>) => {
   return (
     <div
+      {...rest}
       className={`dropdown-menu-item-base dropdown-menu-item-custom ${className}`.trim()}
-      style={style}
-      data-testid={dataTestId}
     >
       {children}
     </div>

+ 4 - 11
src/components/dropdownMenu/DropdownMenuItemLink.tsx

@@ -3,33 +3,26 @@ import React from "react";
 import { getDrodownMenuItemClassName } from "./DropdownMenuItem";
 const DropdownMenuItemLink = ({
   icon,
-  dataTestId,
   shortcut,
   href,
   children,
   className = "",
-  style,
-  ariaLabel,
+  ...rest
 }: {
   icon?: JSX.Element;
   children: React.ReactNode;
-  dataTestId?: string;
   shortcut?: string;
   className?: string;
   href: string;
-  style?: React.CSSProperties;
-  ariaLabel?: string;
-}) => {
+} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
   return (
     <a
+      {...rest}
       href={href}
       target="_blank"
       rel="noreferrer"
       className={getDrodownMenuItemClassName(className)}
-      style={style}
-      data-testid={dataTestId}
-      title={ariaLabel}
-      aria-label={ariaLabel}
+      title={rest.title ?? rest["aria-label"]}
     >
       <MenuItemContent icon={icon} shortcut={shortcut}>
         {children}

+ 8 - 16
src/components/footer/Footer.tsx

@@ -1,8 +1,11 @@
 import clsx from "clsx";
 import { actionShortcuts } from "../../actions";
 import { ActionManager } from "../../actions/manager";
-import { t } from "../../i18n";
-import { AppState, UIChildrenComponents } from "../../types";
+import {
+  AppState,
+  UIChildrenComponents,
+  UIWelcomeScreenComponents,
+} from "../../types";
 import {
   ExitZenModeAction,
   FinalizeAction,
@@ -11,23 +14,21 @@ import {
 } from "../Actions";
 import { useDevice } from "../App";
 import { HelpButton } from "../HelpButton";
-import { WelcomeScreenHelpArrow } from "../icons";
 import { Section } from "../Section";
 import Stack from "../Stack";
-import WelcomeScreenDecor from "../WelcomeScreenDecor";
 
 const Footer = ({
   appState,
   actionManager,
   showExitZenModeBtn,
-  renderWelcomeScreen,
   footerCenter,
+  welcomeScreenHelp,
 }: {
   appState: AppState;
   actionManager: ActionManager;
   showExitZenModeBtn: boolean;
-  renderWelcomeScreen: boolean;
   footerCenter: UIChildrenComponents["FooterCenter"];
+  welcomeScreenHelp: UIWelcomeScreenComponents["HelpHint"];
 }) => {
   const device = useDevice();
   const showFinalize =
@@ -79,17 +80,8 @@ const Footer = ({
         })}
       >
         <div style={{ position: "relative" }}>
-          <WelcomeScreenDecor
-            shouldRender={renderWelcomeScreen && !appState.isLoading}
-          >
-            <div className="virgil WelcomeScreen-decor WelcomeScreen-decor--help-pointer">
-              <div>{t("welcomeScreen.helpHints")}</div>
-              {WelcomeScreenHelpArrow}
-            </div>
-          </WelcomeScreenDecor>
-
+          {welcomeScreenHelp}
           <HelpButton
-            title={t("helpDialog.title")}
             onClick={() => actionManager.executeAction(actionShortcuts)}
           />
         </div>

+ 1 - 1
src/components/icons.tsx

@@ -883,7 +883,7 @@ export const CenterHorizontallyIcon = createIcon(
   modifiedTablerIconProps,
 );
 
-export const UsersIcon = createIcon(
+export const usersIcon = createIcon(
   <g strokeWidth="1.5">
     <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
     <circle cx="9" cy="7" r="4"></circle>

+ 13 - 20
src/components/CollabButton.scss → src/components/live-collaboration/LiveCollaborationTrigger.scss

@@ -1,30 +1,23 @@
-@import "../css/variables.module";
+@import "../../css/variables.module";
 
 .excalidraw {
   .collab-button {
-    @include outlineButtonStyles;
-    width: var(--lg-button-size);
-    height: var(--lg-button-size);
+    --button-bg: var(--color-primary);
+    --button-color: white;
+    --button-border: var(--color-primary);
 
-    svg {
-      width: var(--lg-icon-size);
-      height: var(--lg-icon-size);
-    }
-    background-color: var(--color-primary);
-    border-color: var(--color-primary);
-    color: white;
-    flex-shrink: 0;
+    --button-width: var(--lg-button-size);
+    --button-height: var(--lg-button-size);
 
-    &:hover {
-      background-color: var(--color-primary-darker);
-      border-color: var(--color-primary-darker);
-    }
+    --button-hover-bg: var(--color-primary-darker);
+    --button-hover-border: var(--color-primary-darker);
 
-    &:active {
-      background-color: var(--color-primary-darker);
-    }
+    --button-active-bg: var(--color-primary-darker);
+
+    flex-shrink: 0;
 
-    &.active {
+    // double .active to force specificity
+    &.active.active {
       background-color: #0fb884;
       border-color: #0fb884;
 

+ 40 - 0
src/components/live-collaboration/LiveCollaborationTrigger.tsx

@@ -0,0 +1,40 @@
+import { t } from "../../i18n";
+import { usersIcon } from "../icons";
+import { Button } from "../Button";
+
+import clsx from "clsx";
+import { useExcalidrawAppState } from "../App";
+
+import "./LiveCollaborationTrigger.scss";
+
+const LiveCollaborationTrigger = ({
+  isCollaborating,
+  onSelect,
+  ...rest
+}: {
+  isCollaborating: boolean;
+  onSelect: () => void;
+} & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
+  const appState = useExcalidrawAppState();
+
+  return (
+    <Button
+      {...rest}
+      className={clsx("collab-button", { active: isCollaborating })}
+      type="button"
+      onSelect={onSelect}
+      style={{ position: "relative" }}
+      title={t("labels.liveCollaboration")}
+    >
+      {usersIcon}
+      {appState.collaborators.size > 0 && (
+        <div className="CollabButton-collaborators">
+          {appState.collaborators.size}
+        </div>
+      )}
+    </Button>
+  );
+};
+
+export default LiveCollaborationTrigger;
+LiveCollaborationTrigger.displayName = "LiveCollaborationTrigger";

+ 0 - 0
src/components/mainMenu/DefaultItems.scss → src/components/main-menu/DefaultItems.scss


+ 23 - 23
src/components/mainMenu/DefaultItems.tsx → src/components/main-menu/DefaultItems.tsx

@@ -1,4 +1,3 @@
-import clsx from "clsx";
 import { getShortcutFromShortcutName } from "../../actions/shortcuts";
 import { t } from "../../i18n";
 import {
@@ -15,7 +14,7 @@ import {
   save,
   SunIcon,
   TrashIcon,
-  UsersIcon,
+  usersIcon,
 } from "../icons";
 import { GithubIcon, DiscordIcon, TwitterIcon } from "../icons";
 import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem";
@@ -31,6 +30,7 @@ import {
 import "./DefaultItems.scss";
 import { useState } from "react";
 import ConfirmDialog from "../ConfirmDialog";
+import clsx from "clsx";
 
 export const LoadScene = () => {
   // FIXME Hack until we tie "t" to lang state
@@ -46,9 +46,9 @@ export const LoadScene = () => {
     <DropdownMenuItem
       icon={LoadIcon}
       onSelect={() => actionManager.executeAction(actionLoadScene)}
-      dataTestId="load-button"
+      data-testid="load-button"
       shortcut={getShortcutFromShortcutName("loadScene")}
-      ariaLabel={t("buttons.load")}
+      aria-label={t("buttons.load")}
     >
       {t("buttons.load")}
     </DropdownMenuItem>
@@ -69,10 +69,10 @@ export const SaveToActiveFile = () => {
   return (
     <DropdownMenuItem
       shortcut={getShortcutFromShortcutName("saveScene")}
-      dataTestId="save-button"
+      data-testid="save-button"
       onSelect={() => actionManager.executeAction(actionSaveToActiveFile)}
       icon={save}
-      ariaLabel={`${t("buttons.save")}`}
+      aria-label={`${t("buttons.save")}`}
     >{`${t("buttons.save")}`}</DropdownMenuItem>
   );
 };
@@ -86,10 +86,10 @@ export const SaveAsImage = () => {
   return (
     <DropdownMenuItem
       icon={ExportImageIcon}
-      dataTestId="image-export-button"
+      data-testid="image-export-button"
       onSelect={() => setAppState({ openDialog: "imageExport" })}
       shortcut={getShortcutFromShortcutName("imageExport")}
-      ariaLabel={t("buttons.exportImage")}
+      aria-label={t("buttons.exportImage")}
     >
       {t("buttons.exportImage")}
     </DropdownMenuItem>
@@ -106,11 +106,11 @@ export const Help = () => {
 
   return (
     <DropdownMenuItem
-      dataTestId="help-menu-item"
+      data-testid="help-menu-item"
       icon={HelpIcon}
       onSelect={() => actionManager.executeAction(actionShortcuts)}
       shortcut="?"
-      ariaLabel={t("helpDialog.title")}
+      aria-label={t("helpDialog.title")}
     >
       {t("helpDialog.title")}
     </DropdownMenuItem>
@@ -136,8 +136,8 @@ export const ClearCanvas = () => {
       <DropdownMenuItem
         icon={TrashIcon}
         onSelect={toggleDialog}
-        dataTestId="clear-canvas-button"
-        ariaLabel={t("buttons.clearReset")}
+        data-testid="clear-canvas-button"
+        aria-label={t("buttons.clearReset")}
       >
         {t("buttons.clearReset")}
       </DropdownMenuItem>
@@ -175,9 +175,9 @@ export const ToggleTheme = () => {
         return actionManager.executeAction(actionToggleTheme);
       }}
       icon={appState.theme === "dark" ? SunIcon : MoonIcon}
-      dataTestId="toggle-dark-mode"
+      data-testid="toggle-dark-mode"
       shortcut={getShortcutFromShortcutName("toggleTheme")}
-      ariaLabel={
+      aria-label={
         appState.theme === "dark"
           ? t("buttons.lightMode")
           : t("buttons.darkMode")
@@ -222,8 +222,8 @@ export const Export = () => {
       onSelect={() => {
         setAppState({ openDialog: "jsonExport" });
       }}
-      dataTestId="json-export-button"
-      ariaLabel={t("buttons.export")}
+      data-testid="json-export-button"
+      aria-label={t("buttons.export")}
     >
       {t("buttons.export")}
     </DropdownMenuItem>
@@ -236,21 +236,21 @@ export const Socials = () => (
     <DropdownMenuItemLink
       icon={GithubIcon}
       href="https://github.com/excalidraw/excalidraw"
-      ariaLabel="GitHub"
+      aria-label="GitHub"
     >
       GitHub
     </DropdownMenuItemLink>
     <DropdownMenuItemLink
       icon={DiscordIcon}
       href="https://discord.gg/UexuTaE"
-      ariaLabel="Discord"
+      aria-label="Discord"
     >
       Discord
     </DropdownMenuItemLink>
     <DropdownMenuItemLink
       icon={TwitterIcon}
       href="https://twitter.com/excalidraw"
-      ariaLabel="Twitter"
+      aria-label="Twitter"
     >
       Twitter
     </DropdownMenuItemLink>
@@ -258,7 +258,7 @@ export const Socials = () => (
 );
 Socials.displayName = "Socials";
 
-export const LiveCollaboration = ({
+export const LiveCollaborationTrigger = ({
   onSelect,
   isCollaborating,
 }: {
@@ -270,8 +270,8 @@ export const LiveCollaboration = ({
   const appState = useExcalidrawAppState();
   return (
     <DropdownMenuItem
-      dataTestId="collab-button"
-      icon={UsersIcon}
+      data-testid="collab-button"
+      icon={usersIcon}
       className={clsx({
         "active-collab": isCollaborating,
       })}
@@ -282,4 +282,4 @@ export const LiveCollaboration = ({
   );
 };
 
-LiveCollaboration.displayName = "LiveCollaboration";
+LiveCollaborationTrigger.displayName = "LiveCollaborationTrigger";

+ 0 - 0
src/components/mainMenu/MainMenu.tsx → src/components/main-menu/MainMenu.tsx


+ 195 - 0
src/components/welcome-screen/WelcomeScreen.Center.tsx

@@ -0,0 +1,195 @@
+import { actionLoadScene, actionShortcuts } from "../../actions";
+import { getShortcutFromShortcutName } from "../../actions/shortcuts";
+import { t } from "../../i18n";
+import {
+  useDevice,
+  useExcalidrawActionManager,
+  useExcalidrawAppState,
+} from "../App";
+import { ExcalLogo, HelpIcon, LoadIcon, usersIcon } from "../icons";
+
+const WelcomeScreenMenuItemContent = ({
+  icon,
+  shortcut,
+  children,
+}: {
+  icon?: JSX.Element;
+  shortcut?: string | null;
+  children: React.ReactNode;
+}) => {
+  const device = useDevice();
+  return (
+    <>
+      <div className="welcome-screen-menu-item__icon">{icon}</div>
+      <div className="welcome-screen-menu-item__text">{children}</div>
+      {shortcut && !device.isMobile && (
+        <div className="welcome-screen-menu-item__shortcut">{shortcut}</div>
+      )}
+    </>
+  );
+};
+WelcomeScreenMenuItemContent.displayName = "WelcomeScreenMenuItemContent";
+
+const WelcomeScreenMenuItem = ({
+  onSelect,
+  children,
+  icon,
+  shortcut,
+  className = "",
+  ...props
+}: {
+  onSelect: () => void;
+  children: React.ReactNode;
+  icon?: JSX.Element;
+  shortcut?: string | null;
+} & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
+  return (
+    <button
+      {...props}
+      type="button"
+      className={`welcome-screen-menu-item ${className}`}
+      onClick={onSelect}
+    >
+      <WelcomeScreenMenuItemContent icon={icon} shortcut={shortcut}>
+        {children}
+      </WelcomeScreenMenuItemContent>
+    </button>
+  );
+};
+WelcomeScreenMenuItem.displayName = "WelcomeScreenMenuItem";
+
+const WelcomeScreenMenuItemLink = ({
+  children,
+  href,
+  icon,
+  shortcut,
+  className = "",
+  ...props
+}: {
+  children: React.ReactNode;
+  href: string;
+  icon?: JSX.Element;
+  shortcut?: string | null;
+} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
+  return (
+    <a
+      {...props}
+      className={`welcome-screen-menu-item ${className}`}
+      href={href}
+      target="_blank"
+      rel="noreferrer"
+    >
+      <WelcomeScreenMenuItemContent icon={icon} shortcut={shortcut}>
+        {children}
+      </WelcomeScreenMenuItemContent>
+    </a>
+  );
+};
+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>
+  );
+};
+Center.displayName = "Center";
+
+const Logo = ({ children }: { children?: React.ReactNode }) => {
+  return (
+    <div className="welcome-screen-center__logo virgil welcome-screen-decor">
+      {children || <>{ExcalLogo} Excalidraw</>}
+    </div>
+  );
+};
+Logo.displayName = "Logo";
+
+const Heading = ({ children }: { children: React.ReactNode }) => {
+  return (
+    <div className="welcome-screen-center__heading welcome-screen-decor virgil">
+      {children}
+    </div>
+  );
+};
+Heading.displayName = "Heading";
+
+const Menu = ({ children }: { children?: React.ReactNode }) => {
+  return <div className="welcome-screen-menu">{children}</div>;
+};
+Menu.displayName = "Menu";
+
+const MenuItemHelp = () => {
+  const actionManager = useExcalidrawActionManager();
+
+  return (
+    <WelcomeScreenMenuItem
+      onSelect={() => actionManager.executeAction(actionShortcuts)}
+      shortcut="?"
+      icon={HelpIcon}
+    >
+      {t("helpDialog.title")}
+    </WelcomeScreenMenuItem>
+  );
+};
+MenuItemHelp.displayName = "MenuItemHelp";
+
+const MenuItemLoadScene = () => {
+  const appState = useExcalidrawAppState();
+  const actionManager = useExcalidrawActionManager();
+
+  if (appState.viewModeEnabled) {
+    return null;
+  }
+
+  return (
+    <WelcomeScreenMenuItem
+      onSelect={() => actionManager.executeAction(actionLoadScene)}
+      shortcut={getShortcutFromShortcutName("loadScene")}
+      icon={LoadIcon}
+    >
+      {t("buttons.load")}
+    </WelcomeScreenMenuItem>
+  );
+};
+MenuItemLoadScene.displayName = "MenuItemLoadScene";
+
+const MenuItemLiveCollaborationTrigger = ({
+  onSelect,
+}: {
+  onSelect: () => any;
+}) => {
+  // FIXME when we tie t() to lang state
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  const appState = useExcalidrawAppState();
+
+  return (
+    <WelcomeScreenMenuItem shortcut={null} onSelect={onSelect} icon={usersIcon}>
+      {t("labels.liveCollaboration")}
+    </WelcomeScreenMenuItem>
+  );
+};
+MenuItemLiveCollaborationTrigger.displayName =
+  "MenuItemLiveCollaborationTrigger";
+
+// -----------------------------------------------------------------------------
+
+Center.Logo = Logo;
+Center.Heading = Heading;
+Center.Menu = Menu;
+Center.MenuItem = WelcomeScreenMenuItem;
+Center.MenuItemLink = WelcomeScreenMenuItemLink;
+Center.MenuItemHelp = MenuItemHelp;
+Center.MenuItemLoadScene = MenuItemLoadScene;
+Center.MenuItemLiveCollaborationTrigger = MenuItemLiveCollaborationTrigger;
+
+export { Center };

+ 42 - 0
src/components/welcome-screen/WelcomeScreen.Hints.tsx

@@ -0,0 +1,42 @@
+import { t } from "../../i18n";
+import {
+  WelcomeScreenHelpArrow,
+  WelcomeScreenMenuArrow,
+  WelcomeScreenTopToolbarArrow,
+} from "../icons";
+
+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")}
+      </div>
+    </div>
+  );
+};
+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")}
+      </div>
+      {WelcomeScreenTopToolbarArrow}
+    </div>
+  );
+};
+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>
+  );
+};
+HelpHint.displayName = "HelpHint";
+
+export { HelpHint, MenuHint, ToolbarHint };

+ 88 - 77
src/components/WelcomeScreen.scss → src/components/welcome-screen/WelcomeScreen.scss

@@ -3,29 +3,39 @@
     font-family: "Virgil";
   }
 
-  .WelcomeScreen-logo {
-    display: flex;
-    align-items: center;
-    column-gap: 0.75rem;
-    font-size: 2.25rem;
+  // WelcomeSreen common
+  // ---------------------------------------------------------------------------
 
-    svg {
-      width: 1.625rem;
-      height: auto;
+  .welcome-screen-decor {
+    pointer-events: none;
+
+    color: var(--color-gray-40);
+  }
+
+  &.theme--dark {
+    .welcome-screen-decor {
+      color: var(--color-gray-60);
     }
   }
 
-  .WelcomeScreen-decor {
-    pointer-events: none;
+  // WelcomeScreen.Hints
+  // ---------------------------------------------------------------------------
 
-    color: var(--color-gray-40);
+  .welcome-screen-decor-hint {
+    @media (max-height: 599px) {
+      display: none !important;
+    }
 
-    &--subheading {
-      font-size: 1.125rem;
-      text-align: center;
+    @media (max-width: 1024px), (max-width: 800px) {
+      .welcome-screen-decor {
+        &--help,
+        &--menu {
+          display: none;
+        }
+      }
     }
 
-    &--help-pointer {
+    &--help {
       display: flex;
       position: absolute;
       right: 0;
@@ -49,7 +59,7 @@
       }
     }
 
-    &--top-toolbar-pointer {
+    &--toolbar {
       position: absolute;
       top: 100%;
       left: 50%;
@@ -58,7 +68,7 @@
       display: flex;
       align-items: baseline;
 
-      &__label {
+      .welcome-screen-decor-hint__label {
         width: 120px;
         position: relative;
         top: -0.5rem;
@@ -74,7 +84,7 @@
       }
     }
 
-    &--menu-pointer {
+    &--menu {
       position: absolute;
       width: 320px;
       font-size: 1rem;
@@ -95,10 +105,19 @@
           transform: scaleX(-1);
         }
       }
+
+      @media (max-width: 860px) {
+        .welcome-screen-decor-hint__label {
+          max-width: 160px;
+        }
+      }
     }
   }
 
-  .WelcomeScreen-container {
+  // WelcomeSreen.Center
+  // ---------------------------------------------------------------------------
+
+  .welcome-screen-center {
     display: flex;
     flex-direction: column;
     gap: 2rem;
@@ -112,7 +131,24 @@
     bottom: 1rem;
   }
 
-  .WelcomeScreen-items {
+  .welcome-screen-center__logo {
+    display: flex;
+    align-items: center;
+    column-gap: 0.75rem;
+    font-size: 2.25rem;
+
+    svg {
+      width: 1.625rem;
+      height: auto;
+    }
+  }
+
+  .welcome-screen-center__heading {
+    font-size: 1.125rem;
+    text-align: center;
+  }
+
+  .welcome-screen-menu {
     display: flex;
     flex-direction: column;
     gap: 2px;
@@ -120,7 +156,7 @@
     align-items: center;
   }
 
-  .WelcomeScreen-item {
+  .welcome-screen-menu-item {
     box-sizing: border-box;
 
     pointer-events: all;
@@ -128,8 +164,10 @@
     color: var(--color-gray-50);
     font-size: 0.875rem;
 
+    width: 100%;
     min-width: 300px;
-    display: flex;
+    max-width: 400px;
+    display: grid;
     align-items: center;
     justify-content: space-between;
 
@@ -140,44 +178,49 @@
 
     border-radius: var(--border-radius-md);
 
-    &__label {
+    grid-template-columns: calc(var(--default-icon-size) + 0.5rem) 1fr 3rem;
+
+    &__text {
       display: flex;
       align-items: center;
+      margin-right: auto;
+      text-align: left;
       column-gap: 0.5rem;
+    }
 
-      svg {
-        width: var(--default-icon-size);
-        height: var(--default-icon-size);
-      }
+    &__icon {
+      width: var(--default-icon-size);
+      height: var(--default-icon-size);
     }
 
     &__shortcut {
+      margin-left: auto;
       color: var(--color-gray-40);
       font-size: 0.75rem;
     }
   }
 
-  &:not(:active) .WelcomeScreen-item:hover {
+  &:not(:active) .welcome-screen-menu-item:hover {
     text-decoration: none;
     background: var(--color-gray-10);
 
-    .WelcomeScreen-item__shortcut {
+    .welcome-screen-menu-item__shortcut {
       color: var(--color-gray-50);
     }
 
-    .WelcomeScreen-item__label {
+    .welcome-screen-menu-item__text {
       color: var(--color-gray-100);
     }
   }
 
-  .WelcomeScreen-item:active {
+  .welcome-screen-menu-item:active {
     background: var(--color-gray-20);
 
-    .WelcomeScreen-item__shortcut {
+    .welcome-screen-menu-item__shortcut {
       color: var(--color-gray-50);
     }
 
-    .WelcomeScreen-item__label {
+    .welcome-screen-menu-item__text {
       color: var(--color-gray-100);
     }
 
@@ -185,7 +228,7 @@
       color: var(--color-promo) !important;
 
       &:hover {
-        .WelcomeScreen-item__label {
+        .welcome-screen-menu-item__text {
           color: var(--color-promo) !important;
         }
       }
@@ -193,11 +236,7 @@
   }
 
   &.theme--dark {
-    .WelcomeScreen-decor {
-      color: var(--color-gray-60);
-    }
-
-    .WelcomeScreen-item {
+    .welcome-screen-menu-item {
       color: var(--color-gray-60);
 
       &__shortcut {
@@ -205,69 +244,41 @@
       }
     }
 
-    &:not(:active) .WelcomeScreen-item:hover {
+    &:not(:active) .welcome-screen-menu-item:hover {
       background: var(--color-gray-85);
 
-      .WelcomeScreen-item__shortcut {
+      .welcome-screen-menu-item__shortcut {
         color: var(--color-gray-50);
       }
 
-      .WelcomeScreen-item__label {
+      .welcome-screen-menu-item__text {
         color: var(--color-gray-10);
       }
     }
 
-    .WelcomeScreen-item:active {
+    .welcome-screen-menu-item:active {
       background-color: var(--color-gray-90);
-      .WelcomeScreen-item__label {
+      .welcome-screen-menu-item__text {
         color: var(--color-gray-10);
       }
     }
   }
 
-  // Can tweak these values but for an initial effort, it looks OK to me
-  @media (max-width: 1024px) {
-    .WelcomeScreen-decor {
-      &--help-pointer,
-      &--menu-pointer {
-        display: none;
-      }
-    }
-  }
-
-  // @media (max-height: 400px) {
-  //   .WelcomeScreen-container {
-  //     margin-top: 0;
-  //   }
-  // }
   @media (max-height: 599px) {
-    .WelcomeScreen-container {
+    .welcome-screen-center {
       margin-top: 4rem;
     }
   }
   @media (min-height: 600px) and (max-height: 900px) {
-    .WelcomeScreen-container {
+    .welcome-screen-center {
       margin-top: 8rem;
     }
   }
-  @media (max-height: 630px) {
-    .WelcomeScreen-decor--top-toolbar-pointer {
-      display: none;
-    }
-  }
-  @media (max-height: 500px) {
-    .WelcomeScreen-container {
+  @media (max-height: 500px), (max-width: 320px) {
+    .welcome-screen-center {
       display: none;
     }
   }
 
-  // @media (max-height: 740px) {
-  //   .WelcomeScreen-decor {
-  //     &--help-pointer,
-  //     &--top-toolbar-pointer,
-  //     &--menu-pointer {
-  //       display: none;
-  //     }
-  //   }
-  // }
+  // ---------------------------------------------------------------------------
 }

+ 17 - 0
src/components/welcome-screen/WelcomeScreen.tsx

@@ -0,0 +1,17 @@
+import { Center } from "./WelcomeScreen.Center";
+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;
+};
+WelcomeScreen.displayName = "WelcomeScreen";
+
+WelcomeScreen.Center = Center;
+WelcomeScreen.Hints = { MenuHint, ToolbarHint, HelpHint };
+
+export default WelcomeScreen;

+ 1 - 8
src/constants.ts

@@ -150,6 +150,7 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
     toggleTheme: null,
     saveAsImage: true,
   },
+  welcomeScreen: true,
 };
 
 // breakpoints
@@ -236,14 +237,6 @@ export const ROUNDNESS = {
   ADAPTIVE_RADIUS: 3,
 } as const;
 
-export const COOKIES = {
-  AUTH_STATE_COOKIE: "excplus-auth",
-} as const;
-
 /** 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,
-);

+ 4 - 4
src/css/styles.scss

@@ -408,7 +408,7 @@
     pointer-events: all;
 
     &:hover {
-      background-color: var(--button-hover);
+      background-color: var(--button-hover-bg);
     }
 
     &:active {
@@ -540,9 +540,9 @@
   }
 
   .mobile-misc-tools-container {
-    position: fixed;
-    top: 5rem;
-    right: 0;
+    position: absolute;
+    top: calc(5rem - var(--editor-container-padding));
+    right: calc(var(--editor-container-padding) * -1);
     display: flex;
     flex-direction: column;
     border: 1px solid var(--sidebar-border-color);

+ 3 - 2
src/css/theme.scss

@@ -35,13 +35,14 @@
   --shadow-island: 0px 7px 14px rgba(0, 0, 0, 0.05),
     0px 0px 3.12708px rgba(0, 0, 0, 0.0798),
     0px 0px 0.931014px rgba(0, 0, 0, 0.1702);
-  --button-hover: var(--color-gray-10);
+  --button-hover-bg: var(--color-gray-10);
   --default-border-color: var(--color-gray-30);
 
   --default-button-size: 2rem;
   --default-icon-size: 1rem;
   --lg-button-size: 2.25rem;
   --lg-icon-size: 1rem;
+  --editor-container-padding: 1rem;
 
   @media screen and (min-device-width: 1921px) {
     --lg-button-size: 2.5rem;
@@ -135,7 +136,7 @@
     --popup-text-inverted-color: #2c2c2c;
     --select-highlight-color: #{$oc-blue-4};
     --text-primary-color: var(--color-gray-40);
-    --button-hover: var(--color-gray-80);
+    --button-hover-bg: var(--color-gray-80);
     --default-border-color: var(--color-gray-80);
     --shadow-island: 0px 13px 33px rgba(0, 0, 0, 0.07),
       0px 4.13px 9.94853px rgba(0, 0, 0, 0.0456112),

+ 15 - 11
src/css/variables.module.scss

@@ -39,11 +39,11 @@
 
   .ToolIcon__icon {
     &:hover {
-      background: var(--button-hover);
+      background: var(--button-hover-bg);
     }
 
     &:active {
-      background: var(--button-hover);
+      background: var(--button-hover-bg);
       border: 1px solid var(--color-primary-darkest);
     }
   }
@@ -54,24 +54,25 @@
   justify-content: center;
   align-items: center;
   padding: 0.625rem;
-  width: var(--default-button-size);
-  height: var(--default-button-size);
+  width: var(--button-width, var(--default-button-size));
+  height: var(--button-height, var(--default-button-size));
   box-sizing: border-box;
   border-width: 1px;
   border-style: solid;
-  border-color: var(--default-border-color);
+  border-color: var(--button-border, var(--default-border-color));
   border-radius: var(--border-radius-lg);
   cursor: pointer;
-  background-color: transparent;
-  color: var(--text-primary-color);
+  background-color: var(--button-bg, var(--island-bg-color));
+  color: var(--button-color, var(--text-primary-color));
 
   &:hover {
-    background-color: var(--button-hover);
+    background-color: var(--button-hover-bg);
+    border-color: var(--button-hover-border, var(--default-border-color));
   }
 
   &:active {
-    background-color: var(--button-hover);
-    border-color: var(--color-primary-darkest);
+    background-color: var(--button-active-bg);
+    border-color: var(--button-active-border, var(--color-primary-darkest));
   }
 
   &.active {
@@ -83,7 +84,10 @@
     }
 
     svg {
-      color: var(--color-primary-darker);
+      color: var(--button-color, var(--color-primary-darker));
+
+      width: var(--button-width, var(--lg-icon-size));
+      height: var(--button-height, var(--lg-icon-size));
     }
   }
 }

+ 4 - 4
src/element/resizeElements.ts

@@ -557,10 +557,10 @@ export const resizeSingleElement = (
     mutateElement(element, {
       scale: [
         // defaulting because scaleX/Y can be 0/-0
-        (Math.sign(scaleX) || stateAtResizeStart.scale[0]) *
-          stateAtResizeStart.scale[0],
-        (Math.sign(scaleY) || stateAtResizeStart.scale[1]) *
-          stateAtResizeStart.scale[1],
+        (Math.sign(newBoundsX2 - stateAtResizeStart.x) ||
+          stateAtResizeStart.scale[0]) * stateAtResizeStart.scale[0],
+        (Math.sign(newBoundsY2 - stateAtResizeStart.y) ||
+          stateAtResizeStart.scale[1]) * stateAtResizeStart.scale[1],
       ],
     });
   }

+ 8 - 0
src/excalidraw-app/app_constants.ts

@@ -38,3 +38,11 @@ export const STORAGE_KEYS = {
   VERSION_DATA_STATE: "version-dataState",
   VERSION_FILES: "version-files",
 } as const;
+
+export const COOKIES = {
+  AUTH_STATE_COOKIE: "excplus-auth",
+} as const;
+
+export const isExcalidrawPlusSignedUser = document.cookie.includes(
+  COOKIES.AUTH_STATE_COOKIE,
+);

+ 1 - 1
src/excalidraw-app/components/ExcalidrawPlusAppLink.tsx

@@ -1,4 +1,4 @@
-import { isExcalidrawPlusSignedUser } from "../../constants";
+import { isExcalidrawPlusSignedUser } from "../app_constants";
 
 export const ExcalidrawPlusAppLink = () => {
   if (!isExcalidrawPlusSignedUser) {

+ 75 - 3
src/excalidraw-app/index.tsx

@@ -1,6 +1,6 @@
 import polyfill from "../polyfill";
 import LanguageDetector from "i18next-browser-languagedetector";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
 import { trackEvent } from "../analytics";
 import { getDefaultAppState } from "../appState";
 import { ErrorDialog } from "../components/ErrorDialog";
@@ -26,6 +26,8 @@ import {
   defaultLang,
   Footer,
   MainMenu,
+  LiveCollaborationTrigger,
+  WelcomeScreen,
 } from "../packages/excalidraw/index";
 import {
   AppState,
@@ -45,6 +47,7 @@ import {
 } from "../utils";
 import {
   FIREBASE_STORAGE_PREFIXES,
+  isExcalidrawPlusSignedUser,
   STORAGE_KEYS,
   SYNC_BROWSER_TABS_TIMEOUT,
 } from "./app_constants";
@@ -608,7 +611,7 @@ const ExcalidrawWrapper = () => {
         <MainMenu.DefaultItems.SaveToActiveFile />
         <MainMenu.DefaultItems.Export />
         <MainMenu.DefaultItems.SaveAsImage />
-        <MainMenu.DefaultItems.LiveCollaboration
+        <MainMenu.DefaultItems.LiveCollaborationTrigger
           isCollaborating={isCollaborating}
           onSelect={() => setCollabDialogShown(true)}
         />
@@ -634,6 +637,63 @@ const ExcalidrawWrapper = () => {
     );
   };
 
+  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%" }}
@@ -645,7 +705,6 @@ const ExcalidrawWrapper = () => {
         ref={excalidrawRefCallback}
         onChange={onChange}
         initialData={initialStatePromiseRef.current.promise}
-        onCollabButtonClick={() => setCollabDialogShown(true)}
         isCollaborating={isCollaborating}
         onPointerUpdate={collabAPI?.onPointerUpdate}
         UIOptions={{
@@ -679,14 +738,27 @@ const ExcalidrawWrapper = () => {
         onLibraryChange={onLibraryChange}
         autoFocus={true}
         theme={theme}
+        renderTopRightUI={(isMobile) => {
+          if (isMobile) {
+            return null;
+          }
+          return (
+            <LiveCollaborationTrigger
+              isCollaborating={isCollaborating}
+              onSelect={() => setCollabDialogShown(true)}
+            />
+          );
+        }}
       >
         {renderMenu()}
+
         <Footer>
           <div style={{ display: "flex", gap: ".5rem", alignItems: "center" }}>
             <ExcalidrawPlusAppLink />
             <EncryptedIcon />
           </div>
         </Footer>
+        {welcomeScreenJSX}
       </Excalidraw>
       {excalidrawAPI && <Collab excalidrawAPI={excalidrawAPI} />}
       {errorMessage && (

+ 11 - 5
src/locales/en.json

@@ -448,10 +448,16 @@
     "d9480f": "Orange 9"
   },
   "welcomeScreen": {
-    "data": "All your data is saved locally in your browser.",
-    "switchToPlusApp": "Did you want to go to the Excalidraw+ instead?",
-    "menuHints": "Export, preferences, languages, ...",
-    "toolbarHints": "Pick a tool & Start drawing!",
-    "helpHints": "Shortcuts & help"
+    "app": {
+      "center_heading": "All your data is saved locally in your browser.",
+      "center_heading_plus": "Did you want to go to the Excalidraw+ instead?",
+      "menuHint": "Export, preferences, languages, ..."
+    },
+    "defaults": {
+      "menuHint": "Export, preferences, and more...",
+      "center_heading": "Diagrams. Made. Simple.",
+      "toolbarHint": "Pick a tool & Start drawing!",
+      "helpHint": "Shortcuts & help"
+    }
   }
 }

+ 177 - 4
src/packages/excalidraw/CHANGELOG.md

@@ -11,22 +11,195 @@ The change should be grouped under one of the below section and must contain PR
 Please add the latest change on the top under the correct section.
 -->
 
-## Unreleased
+## 0.14.1 (2023-01-16)
+
+### Fixes
+
+- remove overflow hidden from button [#6110](https://github.com/excalidraw/excalidraw/pull/6110). This fixes the collaborator count css in the [LiveCollaborationTrigger](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#LiveCollaborationTrigger) component.
+
+## 0.14.0 (2023-01-13)
 
 ### Features
 
+- Support customization for the editor [welcome screen](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#WelcomeScreen) [#6048](https://github.com/excalidraw/excalidraw/pull/6048).
+
 - Expose component API for the Excalidraw main menu [#6034](https://github.com/excalidraw/excalidraw/pull/6034), You can read more about its usage [here](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#MainMenu)
 
-- Render Footer as a component instead of render prop [#5970](https://github.com/excalidraw/excalidraw/pull/5970). You can read more about its usage [here](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#Footer)
+- Support customization for the Excalidraw [main menu](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#MainMenu) [#6034](https://github.com/excalidraw/excalidraw/pull/6034).
 
-#### BREAKING CHANGE
+- [Footer](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#Footer) is now rendered as child component instead of passed as a render prop [#5970](https://github.com/excalidraw/excalidraw/pull/5970).
 
-- With this change, the prop `renderFooter` is now removed.
+- Any top-level children passed to the `<Excalidraw/>` component that do not belong to one of the officially supported Excalidraw children components are now rendered directly inside the Excalidraw container (previously, they weren't rendered at all) [#6096](https://github.com/excalidraw/excalidraw/pull/6096).
+
+- Expose [LiveCollaborationTrigger](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#LiveCollaborationTrigger) component. Replaces `props.onCollabButtonClick` [#6104](https://github.com/excalidraw/excalidraw/pull/6104).
+
+#### BREAKING CHANGES
+
+- `props.onCollabButtonClick` is now removed. You need to render the main menu item yourself, and optionally also render the `<LiveCollaborationTrigger>` component using [renderTopRightUI](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#renderTopRightUI) prop if you want to retain the canvas button at top-right.
+- The prop `renderFooter` is now removed in favor of rendering as a child component.
 
 ### Excalidraw schema
 
 - Merged `appState.currentItemStrokeSharpness` and `appState.currentItemLinearStrokeSharpness` into `appState.currentItemRoundness`. Renamed `changeSharpness` action to `changeRoundness`. Excalidraw element's `strokeSharpness` was changed to `roundness`. Check the PR for types and more details [#5553](https://github.com/excalidraw/excalidraw/pull/5553).
 
+## Excalidraw Library
+
+**_This section lists the updates made to the excalidraw library and will not affect the integration._**
+
+### Features
+
+- Generic button export [#6092](https://github.com/excalidraw/excalidraw/pull/6092)
+
+- Scroll using PageUp and PageDown [#6038](https://github.com/excalidraw/excalidraw/pull/6038)
+
+- Support shrinking text containers to original height when text removed [#6025](https://github.com/excalidraw/excalidraw/pull/6025)
+
+- Move contextMenu into the component tree and control via appState [#6021](https://github.com/excalidraw/excalidraw/pull/6021)
+
+- Allow readonly actions to be used in viewMode [#5982](https://github.com/excalidraw/excalidraw/pull/5982)
+
+- Support labels for arrow 🔥 [#5723](https://github.com/excalidraw/excalidraw/pull/5723)
+
+- Don't add midpoint until dragged beyond a threshold [#5927](https://github.com/excalidraw/excalidraw/pull/5927)
+
+- Changed text copy/paste behaviour [#5786](https://github.com/excalidraw/excalidraw/pull/5786)
+
+- Reintroduce `x` shortcut for `freedraw` [#5840](https://github.com/excalidraw/excalidraw/pull/5840)
+
+- Tweak toolbar shortcuts & remove library shortcut [#5832](https://github.com/excalidraw/excalidraw/pull/5832)
+
+- Clean unused images only after 24hrs (local-only) [#5839](https://github.com/excalidraw/excalidraw/pull/5839)
+
+- Refetch errored/pending images on collab room init load [#5833](https://github.com/excalidraw/excalidraw/pull/5833)
+
+- Stop deleting whole line when no point select in line editor [#5676](https://github.com/excalidraw/excalidraw/pull/5676)
+
+- Editor redesign 🔥 [#5780](https://github.com/excalidraw/excalidraw/pull/5780)
+
+### Fixes
+
+- Mobile tools positioning [#6107](https://github.com/excalidraw/excalidraw/pull/6107)
+
+- Renamed folder MainMenu->main-menu and support rest props [#6103](https://github.com/excalidraw/excalidraw/pull/6103)
+
+- Use position absolute for mobile misc tools [#6099](https://github.com/excalidraw/excalidraw/pull/6099)
+
+- React.memo resolvers not accounting for all props [#6042](https://github.com/excalidraw/excalidraw/pull/6042)
+
+- Image horizontal flip fix + improved tests [#5799](https://github.com/excalidraw/excalidraw/pull/5799)
+
+- Png-exporting does not preserve angles correctly for flipped images [#6085](https://github.com/excalidraw/excalidraw/pull/6085)
+
+- Stale appState of MainMenu defaultItems rendered from Actions [#6074](https://github.com/excalidraw/excalidraw/pull/6074)
+
+- HelpDialog [#6072](https://github.com/excalidraw/excalidraw/pull/6072)
+
+- Show error message on collab save failure [#6063](https://github.com/excalidraw/excalidraw/pull/6063)
+
+- Remove ga from docker build [#6059](https://github.com/excalidraw/excalidraw/pull/6059)
+
+- Use displayName since name gets stripped off when uglifying/minifiyng in production [#6036](https://github.com/excalidraw/excalidraw/pull/6036)
+
+- Remove background from wysiwyg when editing arrow label [#6033](https://github.com/excalidraw/excalidraw/pull/6033)
+
+- Use canvas measureText to calculate width in measureText [#6030](https://github.com/excalidraw/excalidraw/pull/6030)
+
+- Restoring deleted bindings [#6029](https://github.com/excalidraw/excalidraw/pull/6029)
+
+- ColorPicker getColor [#5949](https://github.com/excalidraw/excalidraw/pull/5949)
+
+- Don't push whitespace to next line when exceeding max width during wrapping and make sure to use same width of text editor on DOM when measuring dimensions [#5996](https://github.com/excalidraw/excalidraw/pull/5996)
+
+- Showing `grabbing` cursor when holding `spacebar` [#6015](https://github.com/excalidraw/excalidraw/pull/6015)
+
+- Resize sometimes throwing on missing null-checks [#6013](https://github.com/excalidraw/excalidraw/pull/6013)
+
+- PWA not working after CRA@5 update [#6012](https://github.com/excalidraw/excalidraw/pull/6012)
+
+- Not properly restoring element stroke and bg colors [#6002](https://github.com/excalidraw/excalidraw/pull/6002)
+
+- Avatar outline on safari & center [#5997](https://github.com/excalidraw/excalidraw/pull/5997)
+
+- Chart pasting not working due to removing tab characters [#5987](https://github.com/excalidraw/excalidraw/pull/5987)
+
+- Apply the right type of roundness when pasting styles [#5979](https://github.com/excalidraw/excalidraw/pull/5979)
+
+- Remove editor onpaste handler [#5971](https://github.com/excalidraw/excalidraw/pull/5971)
+
+- Remove blank space [#5950](https://github.com/excalidraw/excalidraw/pull/5950)
+
+- Galego and Kurdî missing in languages plus two locale typos [#5954](https://github.com/excalidraw/excalidraw/pull/5954)
+
+- `ExcalidrawArrowElement` rather than `ExcalidrawArrowEleement` [#5955](https://github.com/excalidraw/excalidraw/pull/5955)
+
+- RenderFooter styling [#5962](https://github.com/excalidraw/excalidraw/pull/5962)
+
+- Repair element bindings on restore [#5956](https://github.com/excalidraw/excalidraw/pull/5956)
+
+- Don't allow whitespaces for bound text [#5939](https://github.com/excalidraw/excalidraw/pull/5939)
+
+- Bindings do not survive history serialization [#5942](https://github.com/excalidraw/excalidraw/pull/5942)
+
+- Dedupe boundElement ids when container duplicated with alt+drag [#5938](https://github.com/excalidraw/excalidraw/pull/5938)
+
+- Scale font correctly when using shift [#5935](https://github.com/excalidraw/excalidraw/pull/5935)
+
+- Always bind to container selected by user [#5880](https://github.com/excalidraw/excalidraw/pull/5880)
+
+- Fonts not rendered on init if `loadingdone` not fired [#5923](https://github.com/excalidraw/excalidraw/pull/5923)
+
+- Stop replacing `del` word with `Delete` [#5897](https://github.com/excalidraw/excalidraw/pull/5897)
+
+- Remove legacy React.render() from the editor [#5893](https://github.com/excalidraw/excalidraw/pull/5893)
+
+- Allow adding text via enter only for text containers [#5891](https://github.com/excalidraw/excalidraw/pull/5891)
+
+- Stop font `loadingdone` loop when rendering element SVGs [#5883](https://github.com/excalidraw/excalidraw/pull/5883)
+
+- Refresh text dimensions only after font load done [#5878](https://github.com/excalidraw/excalidraw/pull/5878)
+
+- Correctly paste contents parsed by `JSON.parse()` as text. [#5868](https://github.com/excalidraw/excalidraw/pull/5868)
+
+- SVG element attributes in icons.tsx [#5871](https://github.com/excalidraw/excalidraw/pull/5871)
+
+- Merge existing text with new when pasted [#5856](https://github.com/excalidraw/excalidraw/pull/5856)
+
+- Disable FAST_REFRESH to fix live reload [#5852](https://github.com/excalidraw/excalidraw/pull/5852)
+
+- Paste clipboard contents into unbound text elements [#5849](https://github.com/excalidraw/excalidraw/pull/5849)
+
+- Compute dimensions of container correctly when text pasted on container [#5845](https://github.com/excalidraw/excalidraw/pull/5845)
+
+- Line editor points rendering below elements [#5781](https://github.com/excalidraw/excalidraw/pull/5781)
+
+- Syncing 1-point lines to remote clients [#5677](https://github.com/excalidraw/excalidraw/pull/5677)
+
+- Incorrectly selecting linear elements on creation while tool-locked [#5785](https://github.com/excalidraw/excalidraw/pull/5785)
+
+- Corrected typo in toggle theme shortcut [#5813](https://github.com/excalidraw/excalidraw/pull/5813)
+
+- Hide canvas-modifying UI in view mode [#5815](https://github.com/excalidraw/excalidraw/pull/5815)
+
+- Fix vertical/horizntal centering icons [#5812](https://github.com/excalidraw/excalidraw/pull/5812)
+
+- Consistent use of ZOOM_STEP [#5801](https://github.com/excalidraw/excalidraw/pull/5801)
+
+- Multiple elements resizing regressions [#5586](https://github.com/excalidraw/excalidraw/pull/5586)
+
+- Changelog typo [#5795](https://github.com/excalidraw/excalidraw/pull/5795)
+
+### Refactor
+
+- Remove unnecessary code [#5933](https://github.com/excalidraw/excalidraw/pull/5933)
+
+### Build
+
+- Move release scripts to use release branch [#5958](https://github.com/excalidraw/excalidraw/pull/5958)
+
+- Stops ignoring .env files from docker context so env variables get set during react app build. [#5809](https://github.com/excalidraw/excalidraw/pull/5809)
+
+---
+
 ## 0.13.0 (2022-10-27)
 
 ### Excalidraw API

+ 208 - 62
src/packages/excalidraw/README.md

@@ -138,9 +138,6 @@ export default function App() {
             console.log("Elements :", elements, "State : ", state)
           }
           onPointerUpdate={(payload) => console.log(payload)}
-          onCollabButtonClick={() =>
-            window.alert("You clicked on collab button")
-          }
           viewModeEnabled={viewModeEnabled}
           zenModeEnabled={zenModeEnabled}
           gridModeEnabled={gridModeEnabled}
@@ -331,7 +328,6 @@ const App = () => {
         onChange: (elements, state) =>
           console.log("Elements :", elements, "State : ", state),
         onPointerUpdate: (payload) => console.log(payload),
-        onCollabButtonClick: () => window.alert("You clicked on collab button"),
         viewModeEnabled: viewModeEnabled,
         zenModeEnabled: zenModeEnabled,
         gridModeEnabled: gridModeEnabled,
@@ -376,7 +372,7 @@ 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 ?
+### 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).
 
@@ -405,45 +401,47 @@ const App = () => {
 };
 ```
 
-This will only for `Desktop` devices.
+Footer is only rendered in the desktop view.
 
-For `mobile` you will need to render it inside the [MainMenu](#mainmenu). You can use the [`useDevice`](#useDevice) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component.
+In the mobile view you can render it inside the [MainMenu](#mainmenu) (later we will expose other ways to customize the UI). You can use the [`useDevice`](#useDevice) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component.
 
 ```js
 import { useDevice, Footer } from "@excalidraw/excalidraw";
 
-const MobileFooter = ({
-}) => {
+const MobileFooter = () => {
   const device = useDevice();
   if (device.isMobile) {
     return (
       <Footer>
-       <button
-        className="custom-footer"
-        onClick={() => alert("This is custom footer in mobile menu")}
-      >
-        {" "}
-        custom footer{" "}
-      </button>
+        <button
+          className="custom-footer"
+          onClick={() => alert("This is custom footer in mobile menu")}
+        >
+          {" "}
+          custom footer{" "}
+        </button>
       </Footer>
     );
   }
   return null;
-
 };
+
 const App = () => {
   <Excalidraw>
     <MainMenu>
-      <MainMenu.Item onSelect={() => window.alert("Item1")}> Item1 </MainMenu.Item>
-      <MainMenu.Item onSelect={() => window.alert("Item2")}> Item 2 </>
-      <MobileFooter/>
+      <MainMenu.Item onSelect={() => window.alert("Item1")}>
+        Item1
+      </MainMenu.Item>
+      <MainMenu.Item onSelect={() => window.alert("Item2")}>
+        Item2
+      </MainMenu.Item>
+      <MobileFooter />
     </MainMenu>
-  </Excalidraw>
-}
-
+  </Excalidraw>;
+};
 ```
 
-You can visit the[ example](https://ehlz3.csb.app/) for working demo.
+You can visit the [example](https://ehlz3.csb.app/) for working demo.
 
 #### MainMenu
 
@@ -456,11 +454,15 @@ import { MainMenu } from "@excalidraw/excalidraw";
 const App = () => {
   <Excalidraw>
     <MainMenu>
-      <MainMenu.Item onSelect={() => window.alert("Item1")}> Item1 </MainMenu.Item>
-      <MainMenu.Item onSelect={() => window.alert("Item2")}> Item 2 </>
+      <MainMenu.Item onSelect={() => window.alert("Item1")}>
+        Item1
+      </MainMenu.Item>
+      <MainMenu.Item onSelect={() => window.alert("Item2")}>
+        Item2
+      </MainMenu.Item>
     </MainMenu>
-  </Excalidraw>
-}
+  </Excalidraw>;
+};
 ```
 
 **MainMenu**
@@ -469,28 +471,24 @@ This is the `MainMenu` component which you need to import to render the menu wit
 
 **MainMenu.Item**
 
-To render an item, its recommended to use `MainMenu.Item`.
+Use this component to render a menu item.
 
 | Prop | Type | Required | Default | Description |
 | --- | --- | --- | --- | --- |
 | `onSelect` | `Function` | Yes | `undefined` | The handler is triggered when the item is selected. |
 | `children` | `React.ReactNode` | Yes | `undefined` | The content of the menu item |
 | `icon` | `JSX.Element` | No | `undefined` | The icon used in the menu item |
-| `shortcut` | `string` | No | `undefined` | The shortcut to be shown for the menu item |
-| `className` | `string` | No | "" | The class names to be added to the menu item |
-| `style` | `React.CSSProperties` | No | `undefined` | The inline styles to be added to the menu item |
-| `ariaLabel` | `string` | `undefined` | No | The `aria-label` to be added to the item for accessibility |
-| `dataTestId` | `string` | `undefined` | No | The `data-testid` to be added to the item. |
+| `shortcut` | `string` | No |  | The keyboard shortcut (label-only, does not affect behavior) |
 
 **MainMenu.ItemLink**
 
-To render an item as a link, its recommended to use `MainMenu.ItemLink`.
+To render an external link in a menu item, you can use this component.
 
 **Usage**
 
 ```js
 import { MainMenu } from "@excalidraw/excalidraw";
-const App = () => {
+const App = () => (
   <Excalidraw>
     <MainMenu>
       <MainMenu.ItemLink href="https://google.com">Google</MainMenu.ItemLink>
@@ -499,7 +497,7 @@ const App = () => {
       </MainMenu.ItemLink>
     </MainMenu>
   </Excalidraw>;
-};
+);
 ```
 
 | Prop | Type | Required | Default | Description |
@@ -507,11 +505,7 @@ const App = () => {
 | `href` | `string` | Yes | `undefined` | The `href` attribute to be added to the `anchor` element. |
 | `children` | `React.ReactNode` | Yes | `undefined` | The content of the menu item |
 | `icon` | `JSX.Element` | No | `undefined` | The icon used in the menu item |
-| `shortcut` | `string` | No | `undefined` | The shortcut to be shown for the menu item |
-| `className` | `string` | No | "" | The class names to be added to the menu item |
-| `style` | `React.CSSProperties` | No | `undefined` | The inline styles to be added to the menu item |
-| `ariaLabel` | `string` | No | `undefined` | The `aria-label` to be added to the item for accessibility |
-| `dataTestId` | `string` | No | `undefined` | The `data-testid` to be added to the item. |
+| `shortcut` | `string` | No |  | The keyboard shortcut (label-only, does not affect behavior) |
 
 **MainMenu.ItemCustom**
 
@@ -521,7 +515,7 @@ To render a custom item, you can use `MainMenu.ItemCustom`.
 
 ```js
 import { MainMenu } from "@excalidraw/excalidraw";
-const App = () => {
+const App = () => (
   <Excalidraw>
     <MainMenu>
       <MainMenu.ItemCustom>
@@ -535,15 +529,12 @@ const App = () => {
       </MainMenu.ItemCustom>
     </MainMenu>
   </Excalidraw>;
-};
+);
 ```
 
 | Prop | Type | Required | Default | Description |
 | --- | --- | --- | --- | --- |
 | `children` | `React.ReactNode` | Yes | `undefined` | The content of the menu item |
-| `className` | `string` | No | "" | The class names to be added to the menu item |
-| `style` | `React.CSSProperties` | No | `undefined` | The inline styles to be added to the menu item |
-| `dataTestId` | `string` | No | `undefined` | The `data-testid` to be added to the item. |
 
 **MainMenu.DefaultItems**
 
@@ -551,7 +542,7 @@ For the items which are shown in the menu in [excalidraw.com](https://excalidraw
 
 ```js
 import { MainMenu } from "@excalidraw/excalidraw";
-const App = () => {
+const App = () => (
   <Excalidraw>
     <MainMenu>
       <MainMenu.DefaultItems.Socials/>
@@ -560,7 +551,7 @@ const App = () => {
       <MainMenu.Item onSelect={() => window.alert("Item2")}> Item 2 </>
     </MainMenu>
   </Excalidraw>
-}
+)
 ```
 
 Here is a [complete list](https://github.com/excalidraw/excalidraw/blob/master/src/components/mainMenu/DefaultItems.tsx) of the default items.
@@ -571,7 +562,7 @@ To Group item in the main menu, you can use `MainMenu.Group`
 
 ```js
 import { MainMenu } from "@excalidraw/excalidraw";
-const App = () => {
+const App = () => (
   <Excalidraw>
     <MainMenu>
       <MainMenu.Group title="Excalidraw items">
@@ -584,15 +575,176 @@ const App = () => {
       </MainMenu.Group>
     </MainMenu>
   </Excalidraw>
-}
+)
 ```
 
 | Prop | Type | Required | Default | Description |
 | --- | --- | --- | --- | --- |
-| `children ` | `React.ReactNode` | Yes | `undefined` | The content of the `Menu Group` |
-| `title` | `string` | No | `undefined` | The `title` for the grouped items |
-| `className` | `string` | No | "" | The `classname` to be added to the group |
-| `style` | `React.CSsSProperties` | No | `undefined` | The inline `styles` to be added to the group |
+| `children ` | `React.ReactNode` | Yes | `undefined` | The content of the `MainMenu.Group` |
+
+### WelcomeScreen
+
+When the canvas is empty, Excalidraw shows a welcome "splash" screen with a logo, a few quick action items, and hints explaining what some of the UI buttons do. You can customize the welcome screen by rendering the `WelcomeScreen` component inside your Excalidraw instance.
+
+You can also disable the welcome screen altogether by setting `UIOptions.welcomeScreen` to `false`.
+
+**Usage**
+
+```jsx
+import { WelcomScreen } from "@excalidraw/excalidraw";
+const App = () => (
+  <Excalidraw>
+    <WelcomeScreen>
+      <WelcomeScreen.Center>
+        <WelcomeScreen.Center.Heading>
+          Your data are autosaved to the cloud.
+        </WelcomeScreen.Center.Heading>
+        <WelcomeScreen.Center.Menu>
+          <WelcomeScreen.Center.MenuItem
+            onClick={() => console.log("clicked!")}
+          >
+            Click me!
+          </WelcomeScreen.Center.MenuItem>
+          <WelcomeScreen.Center.MenuItemLink href="https://github.com/excalidraw/excalidraw">
+            Excalidraw GitHub
+          </WelcomeScreen.Center.MenuItemLink>
+          <WelcomeScreen.Center.MenuItemHelp />
+        </WelcomeScreen.Center.Menu>
+      </WelcomeScreen.Center>
+    </WelcomeScreen>
+  </Excalidraw>
+);
+```
+
+To disable the WelcomeScreen:
+
+```jsx
+import { WelcomScreen } from "@excalidraw/excalidraw";
+const App = () => <Excalidraw UIOptions={{ welcomeScreen: false }} />;
+```
+
+**WelcomeScreen**
+
+If you render the `<WelcomeScreen>` component, you are responsible for rendering the content.
+
+There are 2 main parts: 1) welcome screen center component, and 2) welcome screen hints.
+
+![WelcomeScreen overview](./welcome-screen-overview.png)
+
+**WelcomeScreen.Center**
+
+This is the center piece of the welcome screen, containing the logo, heading, and menu. All three sub-components are optional, and you can render whatever you wish into the center component.
+
+**WelcomeScreen.Center.Logo**
+
+By default renders the Excalidraw logo and name. Supply `children` to customize.
+
+**WelcomeScreen.Center.Heading**
+
+Supply `children` to change the default message.
+
+**WelcomeScreen.Center.Menu**
+
+Wrapper component for the menu items. You can build your menu using the `<WelcomeScreen.Center.MenuItem>` and `<WelcomeScreen.Center.MenuItemLink>` components, render your own, or render one of the default menu items.
+
+The default menu items are:
+
+- `<WelcomeScreen.Center.MenuItemHelp/>` - opens the help dialog.
+- `<WelcomeScreen.Center.MenuItemLoadScene/>` - open the load file dialog.
+- `<WelcomeScreen.Center.MenuItemLiveCollaborationTrigger/>` - intended to open the live collaboration dialog. Works similarly to [`<LiveCollaborationTrigger>`](#LiveCollaborationTrigger) and you must supply `onSelect()` handler to integrate with your collaboration implementation.
+
+**Usage**
+
+```jsx
+import { WelcomScreen } from "@excalidraw/excalidraw";
+const App = () => (
+  <Excalidraw>
+    <WelcomeScreen>
+      <WelcomeScreen.Center>
+        <WelcomeScreen.Center.Menu>
+          <WelcomeScreen.Center.MenuItem
+            onClick={() => console.log("clicked!")}
+          >
+            Click me!
+          </WelcomeScreen.Center.MenuItem>
+          <WelcomeScreen.Center.MenuItemLink href="https://github.com/excalidraw/excalidraw">
+            Excalidraw GitHub
+          </WelcomeScreen.Center.MenuItemLink>
+          <WelcomeScreen.Center.MenuItemHelp />
+        </WelcomeScreen.Center.Menu>
+      </WelcomeScreen.Center>
+    </WelcomeScreen>
+  </Excalidraw>
+);
+```
+
+**WelcomeScreen.Center.MenuItem**
+
+Use this component to render a menu item.
+
+| Prop | Type | Required | Default | Description |
+| --- | --- | --- | --- | --- |
+| `onSelect` | `Function` | Yes |  | The handler is triggered when the item is selected. |
+| `children` | `React.ReactNode` | Yes |  | The content of the menu item |
+| `icon` | `JSX.Element` | No |  | The icon used in the menu item |
+| `shortcut` | `string` | No |  | The keyboard shortcut (label-only, does not affect behavior) |
+
+**WelcomeScreen.Center.MenuItemLink**
+
+To render an external link in a menu item, you can use this component.
+
+| Prop | Type | Required | Default | Description |
+| --- | --- | --- | --- | --- |
+| `href` | `string` | Yes |  | The `href` attribute to be added to the `anchor` element. |
+| `children` | `React.ReactNode` | Yes |  | The content of the menu item |
+| `icon` | `JSX.Element` | No |  | The icon used in the menu item |
+| `shortcut` | `string` | No |  | The keyboard shortcut (label-only, does not affect behavior) |
+
+**WelcomeScreen.Hints**
+
+These subcomponents render the UI hints. Text of each hint can be customized by supplying `children`.
+
+**WelcomeScreen.Hints.Menu**
+
+Hint for the main menu. Supply `children` to customize the hint text.
+
+**WelcomeScreen.Hints.Toolbar**
+
+Hint for the toolbar. Supply `children` to customize the hint text.
+
+**WelcomeScreen.Hints.Help**
+
+Hint for the help dialog. Supply `children` to customize the hint text.
+
+### LiveCollaborationTrigger
+
+If you implement live collaboration support and want to expose the same UI button as on excalidraw.com, you can render the `<LiveCollaborationTrigger>` component using the [renderTopRightUI](#rendertoprightui) prop. You'll need to supply `onSelect()` to handle opening of your collaboration dialog, but the button will display current `appState.collaborators` count for you.
+
+| Prop | Type | Required | Default | Description |
+| --- | --- | --- | --- | --- |
+| `onSelect` | `() => any` | Yes |  | Handler called when the user click on the button |
+| `isCollaborating` | `boolean` | Yes | false | Whether live collaboration session is in effect. Modifies button style. |
+
+**Usage**
+
+```jsx
+import { LiveCollaborationTrigger } from "@excalidraw/excalidraw";
+const App = () => (
+  <Excalidraw
+    renderTopRightUI={(isMobile) => {
+      if (isMobile) {
+        return null;
+      }
+      return (
+        <LiveCollaborationTrigger
+          isCollaborating={isCollaborating}
+          onSelect={() => setCollabDialogShown(true)}
+        />
+      );
+    }}
+  />
+);
+```
 
 ### Props
 
@@ -601,7 +753,6 @@ const App = () => {
 | [`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) | <code>{elements?: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L106">ExcalidrawElement[]</a>, appState?: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L79">AppState<a> } </code> | null | The initial data with which app loads. |
 | [`ref`](#ref) | [`createRef`](https://reactjs.org/docs/refs-and-the-dom.html#creating-refs) &#124; [`useRef`](https://reactjs.org/docs/hooks-reference.html#useref) &#124; [`callbackRef`](https://reactjs.org/docs/refs-and-the-dom.html#callback-refs) &#124; <code>{ current: { readyPromise: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L317">resolvablePromise</a> } }</code> |  | 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. |
 | [`langCode`](#langCode) | string | `en` | Language code string |
@@ -775,10 +926,6 @@ You can use this function to update the library. It accepts the below attributes
 
 Adds supplied files data to the `appState.files` cache on top of existing files present in the cache.
 
-#### `onCollabButtonClick`
-
-This callback is triggered when clicked on the collab button in excalidraw. If not supplied, the collab dialog button is not rendered.
-
 #### `isCollaborating`
 
 This prop indicates if the app is in collaboration mode.
@@ -1565,8 +1712,7 @@ This hook can be used to check the type of device which is being used. It can on
 ```js
 import { useDevice, Footer } from "@excalidraw/excalidraw";
 
-const MobileFooter = ({
-}) => {
+const MobileFooter = () => {
   const device = useDevice();
   if (device.isMobile) {
     return (

+ 28 - 21
src/packages/excalidraw/example/App.tsx

@@ -86,24 +86,13 @@ const {
   Sidebar,
   Footer,
   MainMenu,
+  LiveCollaborationTrigger,
 } = window.ExcalidrawLib;
 
 const COMMENT_ICON_DIMENSION = 32;
 const COMMENT_INPUT_HEIGHT = 50;
 const COMMENT_INPUT_WIDTH = 150;
 
-const renderTopRightUI = () => {
-  return (
-    <button
-      onClick={() => alert("This is dummy top right UI")}
-      style={{ height: "2.5rem" }}
-    >
-      {" "}
-      Click me{" "}
-    </button>
-  );
-};
-
 export default function App() {
   const appRef = useRef<any>(null);
   const [viewModeEnabled, setViewModeEnabled] = useState(false);
@@ -164,6 +153,28 @@ export default function App() {
     fetchData();
   }, [excalidrawAPI]);
 
+  const renderTopRightUI = (isMobile: boolean) => {
+    return (
+      <>
+        {!isMobile && (
+          <LiveCollaborationTrigger
+            isCollaborating={isCollaborating}
+            onSelect={() => {
+              window.alert("Collab dialog clicked");
+            }}
+          />
+        )}
+        <button
+          onClick={() => alert("This is dummy top right UI")}
+          style={{ height: "2.5rem" }}
+        >
+          {" "}
+          Click me{" "}
+        </button>
+      </>
+    );
+  };
+
   const loadSceneOrLibrary = async () => {
     const file = await fileOpen({ description: "Excalidraw or library file" });
     const contents = await loadSceneOrLibraryFromBlob(file, null, null);
@@ -505,12 +516,10 @@ export default function App() {
         <MainMenu.DefaultItems.SaveAsImage />
         <MainMenu.DefaultItems.Export />
         <MainMenu.Separator />
-        {isCollaborating && (
-          <MainMenu.DefaultItems.LiveCollaboration
-            onSelect={() => window.alert("You clicked on collab button")}
-            isCollaborating={isCollaborating}
-          />
-        )}
+        <MainMenu.DefaultItems.LiveCollaborationTrigger
+          isCollaborating={isCollaborating}
+          onSelect={() => window.alert("You clicked on collab button")}
+        />
         <MainMenu.Group title="Excalidraw links">
           <MainMenu.DefaultItems.Socials />
         </MainMenu.Group>
@@ -524,6 +533,7 @@ export default function App() {
           </button>
         </MainMenu.ItemCustom>
         <MainMenu.DefaultItems.Help />
+
         {excalidrawAPI && <MobileFooter excalidrawAPI={excalidrawAPI} />}
       </MainMenu>
     );
@@ -693,9 +703,6 @@ export default function App() {
               button: "down" | "up";
               pointersMap: Gesture["pointers"];
             }) => setPointerData(payload)}
-            onCollabButtonClick={() =>
-              window.alert("You clicked on collab button")
-            }
             viewModeEnabled={viewModeEnabled}
             zenModeEnabled={zenModeEnabled}
             gridModeEnabled={gridModeEnabled}

+ 10 - 0
src/packages/excalidraw/example/CustomFooter.tsx

@@ -1,5 +1,7 @@
 import { ExcalidrawImperativeAPI } from "../../../types";
 import { MIME_TYPES } from "../entry";
+import { Button } from "../../../components/Button";
+
 const COMMENT_SVG = (
   <svg
     xmlns="http://www.w3.org/2000/svg"
@@ -23,6 +25,14 @@ const CustomFooter = ({
 }) => {
   return (
     <>
+      <Button
+        onSelect={() => alert("General Kenobi!")}
+        className="you are a bold one"
+        style={{ marginLeft: "1rem" }}
+        title="Hello there!"
+      >
+        {COMMENT_SVG}
+      </Button>
       <button
         className="custom-element"
         onClick={() => {

+ 16 - 12
src/packages/excalidraw/index.tsx

@@ -1,6 +1,7 @@
 import React, { useEffect, forwardRef } from "react";
 import { InitializeApp } from "../../components/InitializeApp";
 import App from "../../components/App";
+import { isShallowEqual } from "../../utils";
 
 import "../../css/app.scss";
 import "../../css/styles.scss";
@@ -11,14 +12,15 @@ import { DEFAULT_UI_OPTIONS } from "../../constants";
 import { Provider } from "jotai";
 import { jotaiScope, jotaiStore } from "../../jotai";
 import Footer from "../../components/footer/FooterCenter";
-import MainMenu from "../../components/mainMenu/MainMenu";
+import MainMenu from "../../components/main-menu/MainMenu";
+import WelcomeScreen from "../../components/welcome-screen/WelcomeScreen";
+import LiveCollaborationTrigger from "../../components/live-collaboration/LiveCollaborationTrigger";
 
 const ExcalidrawBase = (props: ExcalidrawProps) => {
   const {
     onChange,
     initialData,
     excalidrawRef,
-    onCollabButtonClick,
     isCollaborating = false,
     onPointerUpdate,
     renderTopRightUI,
@@ -51,6 +53,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
       ...DEFAULT_UI_OPTIONS.canvasActions,
       ...canvasActions,
     },
+    welcomeScreen: props.UIOptions?.welcomeScreen ?? true,
   };
 
   if (canvasActions?.export) {
@@ -91,7 +94,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
           onChange={onChange}
           initialData={initialData}
           excalidrawRef={excalidrawRef}
-          onCollabButtonClick={onCollabButtonClick}
           isCollaborating={isCollaborating}
           onPointerUpdate={onPointerUpdate}
           renderTopRightUI={renderTopRightUI}
@@ -128,6 +130,11 @@ const areEqual = (
   prevProps: PublicExcalidrawProps,
   nextProps: PublicExcalidrawProps,
 ) => {
+  // short-circuit early
+  if (prevProps.children !== nextProps.children) {
+    return false;
+  }
+
   const {
     initialData: prevInitialData,
     UIOptions: prevUIOptions = {},
@@ -156,7 +163,7 @@ const areEqual = (
       const canvasOptionKeys = Object.keys(
         prevUIOptions.canvasActions!,
       ) as (keyof Partial<typeof DEFAULT_UI_OPTIONS.canvasActions>)[];
-      canvasOptionKeys.every((key) => {
+      return canvasOptionKeys.every((key) => {
         if (
           key === "export" &&
           prevUIOptions?.canvasActions?.export &&
@@ -173,16 +180,10 @@ const areEqual = (
         );
       });
     }
-    return true;
+    return prevUIOptions[key] === nextUIOptions[key];
   });
 
-  const prevKeys = Object.keys(prevProps) as (keyof typeof prev)[];
-  const nextKeys = Object.keys(nextProps) as (keyof typeof next)[];
-  return (
-    isUIOptionsSame &&
-    prevKeys.length === nextKeys.length &&
-    prevKeys.every((key) => prev[key] === next[key])
-  );
+  return isUIOptionsSame && isShallowEqual(prev, next);
 };
 
 const forwardedRefComp = forwardRef<
@@ -239,6 +240,9 @@ export {
 } from "../../utils";
 
 export { Sidebar } from "../../components/Sidebar/Sidebar";
+export { Button } from "../../components/Button";
 export { Footer };
 export { MainMenu };
 export { useDevice } from "../../components/App";
+export { WelcomeScreen };
+export { LiveCollaborationTrigger };

+ 1 - 1
src/packages/excalidraw/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@excalidraw/excalidraw",
-  "version": "0.13.0",
+  "version": "0.14.1",
   "main": "main.js",
   "types": "types/packages/excalidraw/index.d.ts",
   "files": [

BIN
src/packages/excalidraw/welcome-screen-overview.png


+ 27 - 23
src/renderer/renderElement.ts

@@ -713,22 +713,8 @@ const drawElementFromCanvas = (
   const cx = ((x1 + x2) / 2 + renderConfig.scrollX) * window.devicePixelRatio;
   const cy = ((y1 + y2) / 2 + renderConfig.scrollY) * window.devicePixelRatio;
 
-  const _isPendingImageElement = isPendingImageElement(element, renderConfig);
-
-  const scaleXFactor =
-    "scale" in elementWithCanvas.element && !_isPendingImageElement
-      ? elementWithCanvas.element.scale[0]
-      : 1;
-  const scaleYFactor =
-    "scale" in elementWithCanvas.element && !_isPendingImageElement
-      ? elementWithCanvas.element.scale[1]
-      : 1;
-
   context.save();
-  context.scale(
-    (1 / window.devicePixelRatio) * scaleXFactor,
-    (1 / window.devicePixelRatio) * scaleYFactor,
-  );
+  context.scale(1 / window.devicePixelRatio, 1 / window.devicePixelRatio);
   const boundTextElement = getBoundTextElement(element);
 
   if (isArrowElement(element) && boundTextElement) {
@@ -793,7 +779,7 @@ const drawElementFromCanvas = (
         zoom,
     );
 
-    context.translate(cx * scaleXFactor, cy * scaleYFactor);
+    context.translate(cx, cy);
     context.drawImage(
       tempCanvas,
       (-(x2 - x1) / 2) * window.devicePixelRatio - offsetX / zoom - padding,
@@ -802,15 +788,30 @@ const drawElementFromCanvas = (
       tempCanvas.height / zoom,
     );
   } else {
-    context.translate(cx * scaleXFactor, cy * scaleYFactor);
+    // we translate context to element center so that rotation and scale
+    // originates from the element center
+    context.translate(cx, cy);
+
+    context.rotate(element.angle);
+
+    if (
+      "scale" in elementWithCanvas.element &&
+      !isPendingImageElement(element, renderConfig)
+    ) {
+      context.scale(
+        elementWithCanvas.element.scale[0],
+        elementWithCanvas.element.scale[1],
+      );
+    }
 
-    context.rotate(element.angle * scaleXFactor * scaleYFactor);
+    // revert afterwards we don't have account for it during drawing
+    context.translate(-cx, -cy);
 
     context.drawImage(
       elementWithCanvas.canvas!,
-      (-(x2 - x1) / 2) * window.devicePixelRatio -
+      (x1 + renderConfig.scrollX) * window.devicePixelRatio -
         (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
-      (-(y2 - y1) / 2) * window.devicePixelRatio -
+      (y1 + renderConfig.scrollY) * window.devicePixelRatio -
         (padding * elementWithCanvas.canvasZoom) / elementWithCanvas.canvasZoom,
       elementWithCanvas.canvas!.width / elementWithCanvas.canvasZoom,
       elementWithCanvas.canvas!.height / elementWithCanvas.canvasZoom,
@@ -905,9 +906,6 @@ export const renderElement = (
         }
         context.save();
         context.translate(cx, cy);
-        if (element.type === "image") {
-          context.scale(element.scale[0], element.scale[1]);
-        }
 
         if (shouldResetImageFilter(element, renderConfig)) {
           context.filter = "none";
@@ -973,6 +971,12 @@ export const renderElement = (
           );
         } else {
           context.rotate(element.angle);
+
+          if (element.type === "image") {
+            // note: scale must be applied *after* rotating
+            context.scale(element.scale[0], element.scale[1]);
+          }
+
           context.translate(-shiftX, -shiftY);
           drawElementOnCanvas(element, rc, context, renderConfig);
         }

+ 2 - 2
src/scene/scroll.ts

@@ -41,8 +41,8 @@ export const centerScrollOn = ({
   zoom: Zoom;
 }) => {
   return {
-    scrollX: (viewportDimensions.width / 2) * (1 / zoom.value) - scenePoint.x,
-    scrollY: (viewportDimensions.height / 2) * (1 / zoom.value) - scenePoint.y,
+    scrollX: viewportDimensions.width / 2 / zoom.value - scenePoint.x,
+    scrollY: viewportDimensions.height / 2 / zoom.value - scenePoint.y,
   };
 };
 

+ 607 - 493
src/tests/flip.test.tsx

@@ -1,22 +1,53 @@
 import ReactDOM from "react-dom";
-import { render } from "./test-utils";
-import App from "../components/App";
-import { defaultLang, setLanguage } from "../i18n";
+import { GlobalTestState, render, waitFor } from "./test-utils";
 import { UI, Pointer } from "./helpers/ui";
 import { API } from "./helpers/api";
 import { actionFlipHorizontal, actionFlipVertical } from "../actions";
+import { getElementAbsoluteCoords } from "../element";
+import {
+  ExcalidrawElement,
+  ExcalidrawImageElement,
+  ExcalidrawLinearElement,
+  FileId,
+} from "../element/types";
+import { newLinearElement } from "../element";
+import ExcalidrawApp from "../excalidraw-app";
+import { mutateElement } from "../element/mutateElement";
+import { NormalizedZoomValue } from "../types";
+import { ROUNDNESS } from "../constants";
 
 const { h } = window;
 
 const mouse = new Pointer("mouse");
-
+jest.mock("../data/blob", () => {
+  const originalModule = jest.requireActual("../data/blob");
+
+  //Prevent Node.js modules errors (document is not defined etc...)
+  return {
+    __esModule: true,
+    ...originalModule,
+    resizeImageFile: (imageFile: File) => imageFile,
+    generateIdFromFile: () => "fileId" as FileId,
+  };
+});
 beforeEach(async () => {
   // Unmount ReactDOM from root
   ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
+
   mouse.reset();
+  localStorage.clear();
+  sessionStorage.clear();
+  jest.clearAllMocks();
 
-  await setLanguage(defaultLang);
-  await render(<App />);
+  Object.assign(document, {
+    elementFromPoint: () => GlobalTestState.canvas,
+  });
+  await render(<ExcalidrawApp />);
+  h.setState({
+    zoom: {
+      value: 1 as NormalizedZoomValue,
+    },
+  });
 });
 
 const createAndSelectOneRectangle = (angle: number = 0) => {
@@ -79,593 +110,676 @@ const createAndReturnOneDraw = (angle: number = 0) => {
   });
 };
 
-const FLIP_PRECISION_DECIMALS = 7;
-
-// Rectangle element
-
-it("flips an unrotated rectangle horizontally correctly", () => {
-  createAndSelectOneRectangle();
-
-  expect(API.getSelectedElements()[0].x).toEqual(0);
+const createLinearElementWithCurveInsideMinMaxPoints = (
+  type: "line" | "arrow",
+  extraProps: any = {},
+) => {
+  return newLinearElement({
+    type,
+    x: 2256.910668124894,
+    y: -2412.5069664197654,
+    width: 1750.4888916015625,
+    height: 410.51605224609375,
+    angle: 0,
+    strokeColor: "#000000",
+    backgroundColor: "#fa5252",
+    fillStyle: "hachure",
+    strokeWidth: 1,
+    strokeStyle: "solid",
+    roughness: 1,
+    opacity: 100,
+    groupIds: [],
+    roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
+    boundElements: null,
+    link: null,
+    locked: false,
+    points: [
+      [0, 0],
+      [-922.4761962890625, 300.3277587890625],
+      [828.0126953125, 410.51605224609375],
+    ],
+    startArrowhead: null,
+    endArrowhead: null,
+  });
+};
 
-  expect(API.getSelectedElements()[0].y).toEqual(0);
+const createLinearElementsWithCurveOutsideMinMaxPoints = (
+  type: "line" | "arrow",
+  extraProps: any = {},
+) => {
+  return newLinearElement({
+    type,
+    x: -1388.6555370382996,
+    y: 1037.698247710191,
+    width: 591.2804897585779,
+    height: 69.32871961377737,
+    angle: 0,
+    strokeColor: "#000000",
+    backgroundColor: "transparent",
+    fillStyle: "hachure",
+    strokeWidth: 1,
+    strokeStyle: "solid",
+    roughness: 1,
+    opacity: 100,
+    groupIds: [],
+    roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
+    boundElements: null,
+    link: null,
+    locked: false,
+    points: [
+      [0, 0],
+      [-584.1485186423079, -15.365636022723947],
+      [-591.2804897585779, 36.09360810181511],
+      [-148.56510566829502, 53.96308359105342],
+    ],
+    startArrowhead: null,
+    endArrowhead: null,
+    ...extraProps,
+  });
+};
 
-  const originalWidth = API.getSelectedElements()[0].width;
-  const originalHeight = API.getSelectedElements()[0].height;
+const checkElementsBoundingBox = async (
+  element1: ExcalidrawElement,
+  element2: ExcalidrawElement,
+  toleranceInPx: number = 0,
+) => {
+  const [x1, y1, x2, y2] = getElementAbsoluteCoords(element1);
+
+  const [x12, y12, x22, y22] = getElementAbsoluteCoords(element2);
+
+  debugger;
+  await waitFor(() => {
+    // Check if width and height did not change
+    expect(x1 - toleranceInPx <= x12 && x12 <= x1 + toleranceInPx).toBeTruthy();
+    expect(y1 - toleranceInPx <= y12 && y12 <= y1 + toleranceInPx).toBeTruthy();
+    expect(x2 - toleranceInPx <= x22 && x22 <= x2 + toleranceInPx).toBeTruthy();
+    expect(y2 - toleranceInPx <= y22 && y22 <= y2 + toleranceInPx).toBeTruthy();
+  });
+};
 
+const checkHorizontalFlip = async (toleranceInPx: number = 0.00001) => {
+  const originalElement = JSON.parse(JSON.stringify(h.elements[0]));
   h.app.actionManager.executeAction(actionFlipHorizontal);
+  const newElement = h.elements[0];
+  await checkElementsBoundingBox(originalElement, newElement, toleranceInPx);
+};
 
-  // Check if x position did not change
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-
-  expect(API.getSelectedElements()[0].y).toEqual(0);
-
-  // Check if width and height did not change
-  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
-
-  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
-});
-
-it("flips an unrotated rectangle vertically correctly", () => {
-  createAndSelectOneRectangle();
-
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-
-  expect(API.getSelectedElements()[0].y).toEqual(0);
-
-  const originalWidth = API.getSelectedElements()[0].width;
-  const originalHeight = API.getSelectedElements()[0].height;
-
-  h.app.actionManager.executeAction(actionFlipVertical);
-
-  // Check if x position did not change
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-
-  expect(API.getSelectedElements()[0].y).toEqual(0);
-
-  // Check if width and height did not change
-  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
-
-  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
-});
-
-it("flips a rotated rectangle horizontally correctly", () => {
-  const originalAngle = (3 * Math.PI) / 4;
-  const expectedAngle = (5 * Math.PI) / 4;
-
-  createAndSelectOneRectangle(originalAngle);
-
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-
-  expect(API.getSelectedElements()[0].y).toEqual(0);
-
-  const originalWidth = API.getSelectedElements()[0].width;
-  const originalHeight = API.getSelectedElements()[0].height;
-
+const checkTwoPointsLineHorizontalFlip = async () => {
+  const originalElement = JSON.parse(
+    JSON.stringify(h.elements[0]),
+  ) as ExcalidrawLinearElement;
   h.app.actionManager.executeAction(actionFlipHorizontal);
+  const newElement = h.elements[0] as ExcalidrawLinearElement;
+  await waitFor(() => {
+    expect(originalElement.points[0][0]).toEqual(
+      newElement.points[0][0] !== 0 ? -newElement.points[0][0] : 0,
+    );
+    expect(originalElement.points[0][1]).toEqual(newElement.points[0][1]);
+    expect(originalElement.points[1][0]).toEqual(
+      newElement.points[1][0] !== 0 ? -newElement.points[1][0] : 0,
+    );
+    expect(originalElement.points[1][1]).toEqual(newElement.points[1][1]);
+  });
+};
 
-  // Check if x position did not change
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-
-  expect(API.getSelectedElements()[0].y).toEqual(0);
-
-  // Check if width and height did not change
-  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
-
-  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
-
-  // Check angle
-  expect(API.getSelectedElements()[0].angle).toBeCloseTo(expectedAngle);
-});
-
-it("flips a rotated rectangle vertically correctly", () => {
-  const originalAngle = (3 * Math.PI) / 4;
-  const expectedAgnle = Math.PI / 4;
-
-  createAndSelectOneRectangle(originalAngle);
-
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-
-  expect(API.getSelectedElements()[0].y).toEqual(0);
-
-  const originalWidth = API.getSelectedElements()[0].width;
-  const originalHeight = API.getSelectedElements()[0].height;
-
+const checkTwoPointsLineVerticalFlip = async () => {
+  const originalElement = JSON.parse(
+    JSON.stringify(h.elements[0]),
+  ) as ExcalidrawLinearElement;
   h.app.actionManager.executeAction(actionFlipVertical);
+  const newElement = h.elements[0] as ExcalidrawLinearElement;
+  await waitFor(() => {
+    expect(originalElement.points[0][0]).toEqual(
+      newElement.points[0][0] !== 0 ? -newElement.points[0][0] : 0,
+    );
+    expect(originalElement.points[0][1]).toEqual(newElement.points[0][1]);
+    expect(originalElement.points[1][0]).toEqual(
+      newElement.points[1][0] !== 0 ? -newElement.points[1][0] : 0,
+    );
+    expect(originalElement.points[1][1]).toEqual(newElement.points[1][1]);
+  });
+};
 
-  // Check if x position did not change
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-
-  expect(API.getSelectedElements()[0].y).toEqual(0);
-
-  // Check if width and height did not change
-  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
-
-  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
-
-  // Check angle
-  expect(API.getSelectedElements()[0].angle).toBeCloseTo(expectedAgnle);
-});
-
-// Diamond element
-
-it("flips an unrotated diamond horizontally correctly", () => {
-  createAndSelectOneDiamond();
-
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-
-  expect(API.getSelectedElements()[0].y).toEqual(0);
-
-  const originalWidth = API.getSelectedElements()[0].width;
-  const originalHeight = API.getSelectedElements()[0].height;
-
+const checkRotatedHorizontalFlip = async (
+  expectedAngle: number,
+  toleranceInPx: number = 0.00001,
+) => {
+  const originalElement = JSON.parse(JSON.stringify(h.elements[0]));
   h.app.actionManager.executeAction(actionFlipHorizontal);
+  const newElement = h.elements[0];
+  await waitFor(() => {
+    expect(newElement.angle).toBeCloseTo(expectedAngle);
+  });
+  await checkElementsBoundingBox(originalElement, newElement, toleranceInPx);
+};
 
-  // Check if x position did not change
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-
-  expect(API.getSelectedElements()[0].y).toEqual(0);
-
-  // Check if width and height did not change
-  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
-
-  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
-});
-
-it("flips an unrotated diamond vertically correctly", () => {
-  createAndSelectOneDiamond();
-
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-
-  expect(API.getSelectedElements()[0].y).toEqual(0);
-
-  const originalWidth = API.getSelectedElements()[0].width;
-  const originalHeight = API.getSelectedElements()[0].height;
-
+const checkRotatedVerticalFlip = async (
+  expectedAngle: number,
+  toleranceInPx: number = 0.00001,
+) => {
+  const originalElement = JSON.parse(JSON.stringify(h.elements[0]));
   h.app.actionManager.executeAction(actionFlipVertical);
+  const newElement = h.elements[0];
+  await waitFor(() => {
+    expect(newElement.angle).toBeCloseTo(expectedAngle);
+  });
+  await checkElementsBoundingBox(originalElement, newElement, toleranceInPx);
+};
 
-  // Check if x position did not change
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-
-  expect(API.getSelectedElements()[0].y).toEqual(0);
-
-  // Check if width and height did not change
-  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
-
-  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
-});
-
-it("flips a rotated diamond horizontally correctly", () => {
-  const originalAngle = (5 * Math.PI) / 4;
-  const expectedAngle = (3 * Math.PI) / 4;
-
-  createAndSelectOneDiamond(originalAngle);
-
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-
-  expect(API.getSelectedElements()[0].y).toEqual(0);
-
-  const originalWidth = API.getSelectedElements()[0].width;
-  const originalHeight = API.getSelectedElements()[0].height;
-
-  h.app.actionManager.executeAction(actionFlipHorizontal);
-
-  // Check if x position did not change
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-
-  expect(API.getSelectedElements()[0].y).toEqual(0);
-
-  // Check if width and height did not change
-  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
-
-  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
-
-  // Check angle
-  expect(API.getSelectedElements()[0].angle).toBeCloseTo(expectedAngle);
-});
-
-it("flips a rotated diamond vertically correctly", () => {
-  const originalAngle = (5 * Math.PI) / 4;
-  const expectedAngle = (7 * Math.PI) / 4;
-
-  createAndSelectOneDiamond(originalAngle);
-
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-
-  expect(API.getSelectedElements()[0].y).toEqual(0);
-
-  const originalWidth = API.getSelectedElements()[0].width;
-  const originalHeight = API.getSelectedElements()[0].height;
+const checkVerticalFlip = async (toleranceInPx: number = 0.00001) => {
+  const originalElement = JSON.parse(JSON.stringify(h.elements[0]));
 
   h.app.actionManager.executeAction(actionFlipVertical);
 
-  // Check if x position did not change
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-
-  expect(API.getSelectedElements()[0].y).toEqual(0);
-
-  // Check if width and height did not change
-  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
-
-  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
-
-  // Check angle
-  expect(API.getSelectedElements()[0].angle).toBeCloseTo(expectedAngle);
-});
-
-// Ellipse element
-
-it("flips an unrotated ellipse horizontally correctly", () => {
-  createAndSelectOneEllipse();
-
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-
-  expect(API.getSelectedElements()[0].y).toEqual(0);
+  const newElement = h.elements[0];
+  await checkElementsBoundingBox(originalElement, newElement, toleranceInPx);
+};
 
-  const originalWidth = API.getSelectedElements()[0].width;
-  const originalHeight = API.getSelectedElements()[0].height;
+const checkVerticalHorizontalFlip = async (toleranceInPx: number = 0.00001) => {
+  const originalElement = JSON.parse(JSON.stringify(h.elements[0]));
 
   h.app.actionManager.executeAction(actionFlipHorizontal);
+  h.app.actionManager.executeAction(actionFlipVertical);
 
-  // Check if x position did not change
-  expect(API.getSelectedElements()[0].x).toEqual(0);
-
-  expect(API.getSelectedElements()[0].y).toEqual(0);
+  const newElement = h.elements[0];
+  await checkElementsBoundingBox(originalElement, newElement, toleranceInPx);
+};
 
-  // Check if width and height did not change
-  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
+const TWO_POINTS_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS = 5;
+const MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS = 20;
 
-  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
-});
+// Rectangle element
+describe("rectangle", () => {
+  it("flips an unrotated rectangle horizontally correctly", async () => {
+    createAndSelectOneRectangle();
 
-it("flips an unrotated ellipse vertically correctly", () => {
-  createAndSelectOneEllipse();
+    await checkHorizontalFlip();
+  });
 
-  expect(API.getSelectedElements()[0].x).toEqual(0);
+  it("flips an unrotated rectangle vertically correctly", async () => {
+    createAndSelectOneRectangle();
 
-  expect(API.getSelectedElements()[0].y).toEqual(0);
+    await checkVerticalFlip();
+  });
 
-  const originalWidth = API.getSelectedElements()[0].width;
-  const originalHeight = API.getSelectedElements()[0].height;
+  it("flips a rotated rectangle horizontally correctly", async () => {
+    const originalAngle = (3 * Math.PI) / 4;
+    const expectedAngle = (5 * Math.PI) / 4;
 
-  h.app.actionManager.executeAction(actionFlipVertical);
+    createAndSelectOneRectangle(originalAngle);
 
-  // Check if x position did not change
-  expect(API.getSelectedElements()[0].x).toEqual(0);
+    await checkRotatedHorizontalFlip(expectedAngle);
+  });
 
-  expect(API.getSelectedElements()[0].y).toEqual(0);
+  it("flips a rotated rectangle vertically correctly", async () => {
+    const originalAngle = (3 * Math.PI) / 4;
+    const expectedAgnle = Math.PI / 4;
 
-  // Check if width and height did not change
-  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
+    createAndSelectOneRectangle(originalAngle);
 
-  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
+    await checkRotatedVerticalFlip(expectedAgnle);
+  });
 });
 
-it("flips a rotated ellipse horizontally correctly", () => {
-  const originalAngle = (7 * Math.PI) / 4;
-  const expectedAngle = Math.PI / 4;
-
-  createAndSelectOneEllipse(originalAngle);
+// Diamond element
+describe("diamond", () => {
+  it("flips an unrotated diamond horizontally correctly", async () => {
+    createAndSelectOneDiamond();
 
-  expect(API.getSelectedElements()[0].x).toEqual(0);
+    await checkHorizontalFlip();
+  });
 
-  expect(API.getSelectedElements()[0].y).toEqual(0);
+  it("flips an unrotated diamond vertically correctly", async () => {
+    createAndSelectOneDiamond();
 
-  const originalWidth = API.getSelectedElements()[0].width;
-  const originalHeight = API.getSelectedElements()[0].height;
+    await checkVerticalFlip();
+  });
 
-  h.app.actionManager.executeAction(actionFlipHorizontal);
+  it("flips a rotated diamond horizontally correctly", async () => {
+    const originalAngle = (5 * Math.PI) / 4;
+    const expectedAngle = (3 * Math.PI) / 4;
 
-  // Check if x position did not change
-  expect(API.getSelectedElements()[0].x).toEqual(0);
+    createAndSelectOneDiamond(originalAngle);
 
-  expect(API.getSelectedElements()[0].y).toEqual(0);
+    await checkRotatedHorizontalFlip(expectedAngle);
+  });
 
-  // Check if width and height did not change
-  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
+  it("flips a rotated diamond vertically correctly", async () => {
+    const originalAngle = (5 * Math.PI) / 4;
+    const expectedAngle = (7 * Math.PI) / 4;
 
-  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
+    createAndSelectOneDiamond(originalAngle);
 
-  // Check angle
-  expect(API.getSelectedElements()[0].angle).toBeCloseTo(expectedAngle);
+    await checkRotatedVerticalFlip(expectedAngle);
+  });
 });
 
-it("flips a rotated ellipse vertically correctly", () => {
-  const originalAngle = (7 * Math.PI) / 4;
-  const expectedAngle = (5 * Math.PI) / 4;
-
-  createAndSelectOneEllipse(originalAngle);
+// Ellipse element
+describe("ellipse", () => {
+  it("flips an unrotated ellipse horizontally correctly", async () => {
+    createAndSelectOneEllipse();
 
-  expect(API.getSelectedElements()[0].x).toEqual(0);
+    await checkHorizontalFlip();
+  });
 
-  expect(API.getSelectedElements()[0].y).toEqual(0);
+  it("flips an unrotated ellipse vertically correctly", async () => {
+    createAndSelectOneEllipse();
 
-  const originalWidth = API.getSelectedElements()[0].width;
-  const originalHeight = API.getSelectedElements()[0].height;
+    await checkVerticalFlip();
+  });
 
-  h.app.actionManager.executeAction(actionFlipVertical);
+  it("flips a rotated ellipse horizontally correctly", async () => {
+    const originalAngle = (7 * Math.PI) / 4;
+    const expectedAngle = Math.PI / 4;
 
-  // Check if x position did not change
-  expect(API.getSelectedElements()[0].x).toEqual(0);
+    createAndSelectOneEllipse(originalAngle);
 
-  expect(API.getSelectedElements()[0].y).toEqual(0);
+    await checkRotatedHorizontalFlip(expectedAngle);
+  });
 
-  // Check if width and height did not change
-  expect(API.getSelectedElements()[0].width).toEqual(originalWidth);
+  it("flips a rotated ellipse vertically correctly", async () => {
+    const originalAngle = (7 * Math.PI) / 4;
+    const expectedAngle = (5 * Math.PI) / 4;
 
-  expect(API.getSelectedElements()[0].height).toEqual(originalHeight);
+    createAndSelectOneEllipse(originalAngle);
 
-  // Check angle
-  expect(API.getSelectedElements()[0].angle).toBeCloseTo(expectedAngle);
+    await checkRotatedVerticalFlip(expectedAngle);
+  });
 });
 
 // Arrow element
+describe("arrow", () => {
+  it("flips an unrotated arrow horizontally with line inside min/max points bounds", async () => {
+    const arrow = createLinearElementWithCurveInsideMinMaxPoints("arrow");
+    h.app.scene.replaceAllElements([arrow]);
+    h.app.setState({ selectedElementIds: { [arrow.id]: true } });
+    await checkHorizontalFlip(
+      MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
+    );
+  });
 
-it("flips an unrotated arrow horizontally correctly", () => {
-  createAndSelectOneArrow();
-
-  const originalWidth = API.getSelectedElements()[0].width;
-  const originalHeight = API.getSelectedElements()[0].height;
-
-  h.app.actionManager.executeAction(actionFlipHorizontal);
-
-  // Check if width and height did not change
-  expect(API.getSelectedElements()[0].width).toBeCloseTo(
-    originalWidth,
-    FLIP_PRECISION_DECIMALS,
-  );
-
-  expect(API.getSelectedElements()[0].height).toBeCloseTo(
-    originalHeight,
-    FLIP_PRECISION_DECIMALS,
-  );
-});
-
-it("flips an unrotated arrow vertically correctly", () => {
-  createAndSelectOneArrow();
+  it("flips an unrotated arrow vertically with line inside min/max points bounds", async () => {
+    const arrow = createLinearElementWithCurveInsideMinMaxPoints("arrow");
+    h.app.scene.replaceAllElements([arrow]);
+    h.app.setState({ selectedElementIds: { [arrow.id]: true } });
 
-  const originalWidth = API.getSelectedElements()[0].width;
-  const originalHeight = API.getSelectedElements()[0].height;
+    await checkVerticalFlip(50);
+  });
 
-  h.app.actionManager.executeAction(actionFlipVertical);
+  it("flips a rotated arrow horizontally with line inside min/max points bounds", async () => {
+    const originalAngle = Math.PI / 4;
+    const expectedAngle = (7 * Math.PI) / 4;
+    const line = createLinearElementWithCurveInsideMinMaxPoints("arrow");
+    h.app.scene.replaceAllElements([line]);
+    h.app.state.selectedElementIds[line.id] = true;
+    mutateElement(line, {
+      angle: originalAngle,
+    });
+
+    await checkRotatedHorizontalFlip(
+      expectedAngle,
+      MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
+    );
+  });
 
-  // Check if width and height did not change
-  expect(API.getSelectedElements()[0].width).toBeCloseTo(
-    originalWidth,
-    FLIP_PRECISION_DECIMALS,
-  );
+  it("flips a rotated arrow vertically with line inside min/max points bounds", async () => {
+    const originalAngle = Math.PI / 4;
+    const expectedAngle = (3 * Math.PI) / 4;
+    const line = createLinearElementWithCurveInsideMinMaxPoints("arrow");
+    h.app.scene.replaceAllElements([line]);
+    h.app.state.selectedElementIds[line.id] = true;
+    mutateElement(line, {
+      angle: originalAngle,
+    });
+
+    await checkRotatedVerticalFlip(
+      expectedAngle,
+      MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
+    );
+  });
 
-  expect(API.getSelectedElements()[0].height).toBeCloseTo(
-    originalHeight,
-    FLIP_PRECISION_DECIMALS,
-  );
-});
+  //TODO: elements with curve outside minMax points have a wrong bounding box!!!
+  it.skip("flips an unrotated arrow horizontally with line outside min/max points bounds", async () => {
+    const arrow = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
+    h.app.scene.replaceAllElements([arrow]);
+    h.app.setState({ selectedElementIds: { [arrow.id]: true } });
 
-//@TODO fix the tests with rotation
-it.skip("flips a rotated arrow horizontally correctly", () => {
-  const originalAngle = Math.PI / 4;
-  const expectedAngle = (7 * Math.PI) / 4;
-  createAndSelectOneArrow(originalAngle);
+    await checkHorizontalFlip(
+      MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
+    );
+  });
 
-  const originalWidth = API.getSelectedElements()[0].width;
-  const originalHeight = API.getSelectedElements()[0].height;
+  //TODO: elements with curve outside minMax points have a wrong bounding box!!!
+  it.skip("flips a rotated arrow horizontally with line outside min/max points bounds", async () => {
+    const originalAngle = Math.PI / 4;
+    const expectedAngle = (7 * Math.PI) / 4;
+    const line = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
+    mutateElement(line, { angle: originalAngle });
+    h.app.scene.replaceAllElements([line]);
+    h.app.setState({ selectedElementIds: { [line.id]: true } });
+
+    await checkRotatedVerticalFlip(
+      expectedAngle,
+      MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
+    );
+  });
 
-  h.app.actionManager.executeAction(actionFlipHorizontal);
+  //TODO: elements with curve outside minMax points have a wrong bounding box!!!
+  it.skip("flips an unrotated arrow vertically with line outside min/max points bounds", async () => {
+    const arrow = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
+    h.app.scene.replaceAllElements([arrow]);
+    h.app.setState({ selectedElementIds: { [arrow.id]: true } });
 
-  // Check if width and height did not change
-  expect(API.getSelectedElements()[0].width).toBeCloseTo(
-    originalWidth,
-    FLIP_PRECISION_DECIMALS,
-  );
-
-  expect(API.getSelectedElements()[0].height).toBeCloseTo(
-    originalHeight,
-    FLIP_PRECISION_DECIMALS,
-  );
-  // Check angle
-  expect(API.getSelectedElements()[0].angle).toBeCloseTo(expectedAngle);
-});
+    await checkVerticalFlip(MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS);
+  });
 
-it.skip("flips a rotated arrow vertically correctly", () => {
-  const originalAngle = Math.PI / 4;
-  const expectedAngle = (3 * Math.PI) / 4;
-  createAndSelectOneArrow(originalAngle);
+  //TODO: elements with curve outside minMax points have a wrong bounding box!!!
+  it.skip("flips a rotated arrow vertically with line outside min/max points bounds", async () => {
+    const originalAngle = Math.PI / 4;
+    const expectedAngle = (3 * Math.PI) / 4;
+    const line = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
+    mutateElement(line, { angle: originalAngle });
+    h.app.scene.replaceAllElements([line]);
+    h.app.setState({ selectedElementIds: { [line.id]: true } });
+
+    await checkRotatedVerticalFlip(
+      expectedAngle,
+      MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
+    );
+  });
 
-  const originalWidth = API.getSelectedElements()[0].width;
-  const originalHeight = API.getSelectedElements()[0].height;
+  it("flips an unrotated arrow horizontally correctly", async () => {
+    createAndSelectOneArrow();
+    await checkHorizontalFlip(
+      TWO_POINTS_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
+    );
+  });
 
-  h.app.actionManager.executeAction(actionFlipVertical);
+  it("flips an unrotated arrow vertically correctly", async () => {
+    createAndSelectOneArrow();
+    await checkVerticalFlip(TWO_POINTS_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS);
+  });
 
-  // Check if width and height did not change
-  expect(API.getSelectedElements()[0].width).toBeCloseTo(
-    originalWidth,
-    FLIP_PRECISION_DECIMALS,
-  );
+  it("flips a two points arrow horizontally correctly", async () => {
+    createAndSelectOneArrow();
+    await checkTwoPointsLineHorizontalFlip();
+  });
 
-  expect(API.getSelectedElements()[0].height).toBeCloseTo(
-    originalHeight,
-    FLIP_PRECISION_DECIMALS,
-  );
+  it("flips a two points arrow vertically correctly", async () => {
+    createAndSelectOneArrow();
 
-  // Check angle
-  expect(API.getSelectedElements()[0].angle).toBeCloseTo(expectedAngle);
+    await checkTwoPointsLineVerticalFlip();
+  });
 });
 
 // Line element
+describe("line", () => {
+  it("flips an unrotated line horizontally with line inside min/max points bounds", async () => {
+    const line = createLinearElementWithCurveInsideMinMaxPoints("line");
+    h.app.scene.replaceAllElements([line]);
+    h.app.setState({ selectedElementIds: { [line.id]: true } });
+
+    await checkHorizontalFlip(
+      MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
+    );
+  });
 
-it("flips an unrotated line horizontally correctly", () => {
-  createAndSelectOneLine();
-
-  const originalWidth = API.getSelectedElements()[0].width;
-  const originalHeight = API.getSelectedElements()[0].height;
-
-  h.app.actionManager.executeAction(actionFlipHorizontal);
-
-  // Check if width and height did not change
-  expect(API.getSelectedElements()[0].width).toBeCloseTo(
-    originalWidth,
-    FLIP_PRECISION_DECIMALS,
-  );
-
-  expect(API.getSelectedElements()[0].height).toBeCloseTo(
-    originalHeight,
-    FLIP_PRECISION_DECIMALS,
-  );
-});
-
-it("flips an unrotated line vertically correctly", () => {
-  createAndSelectOneLine();
-
-  const originalWidth = API.getSelectedElements()[0].width;
-  const originalHeight = API.getSelectedElements()[0].height;
-
-  h.app.actionManager.executeAction(actionFlipVertical);
-
-  // Check if width and height did not change
-  expect(API.getSelectedElements()[0].width).toBeCloseTo(
-    originalWidth,
-    FLIP_PRECISION_DECIMALS,
-  );
-
-  expect(API.getSelectedElements()[0].height).toBeCloseTo(
-    originalHeight,
-    FLIP_PRECISION_DECIMALS,
-  );
-});
-
-it.skip("flips a rotated line horizontally correctly", () => {
-  const originalAngle = Math.PI / 4;
-  const expectedAngle = (7 * Math.PI) / 4;
-
-  createAndSelectOneLine(originalAngle);
-
-  const originalWidth = API.getSelectedElements()[0].width;
-  const originalHeight = API.getSelectedElements()[0].height;
+  it("flips an unrotated line vertically with line inside min/max points bounds", async () => {
+    const line = createLinearElementWithCurveInsideMinMaxPoints("line");
+    h.app.scene.replaceAllElements([line]);
+    h.app.setState({ selectedElementIds: { [line.id]: true } });
 
-  h.app.actionManager.executeAction(actionFlipHorizontal);
+    await checkVerticalFlip(MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS);
+  });
 
-  // Check if width and height did not change
-  expect(API.getSelectedElements()[0].width).toBeCloseTo(
-    originalWidth,
-    FLIP_PRECISION_DECIMALS,
-  );
+  it("flips an unrotated line horizontally correctly", async () => {
+    createAndSelectOneLine();
+    await checkHorizontalFlip(
+      TWO_POINTS_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
+    );
+  });
+  //TODO: elements with curve outside minMax points have a wrong bounding box
+  it.skip("flips an unrotated line horizontally with line outside min/max points bounds", async () => {
+    const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
+    h.app.scene.replaceAllElements([line]);
+    h.app.setState({ selectedElementIds: { [line.id]: true } });
+
+    await checkHorizontalFlip(
+      MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
+    );
+  });
 
-  expect(API.getSelectedElements()[0].height).toBeCloseTo(
-    originalHeight,
-    FLIP_PRECISION_DECIMALS,
-  );
+  //TODO: elements with curve outside minMax points have a wrong bounding box
+  it.skip("flips an unrotated line vertically with line outside min/max points bounds", async () => {
+    const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
+    h.app.scene.replaceAllElements([line]);
+    h.app.setState({ selectedElementIds: { [line.id]: true } });
 
-  // Check angle
-  expect(API.getSelectedElements()[0].angle).toBeCloseTo(expectedAngle);
-});
+    await checkVerticalFlip(MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS);
+  });
 
-it.skip("flips a rotated line vertically correctly", () => {
-  const originalAngle = Math.PI / 4;
-  const expectedAngle = (3 * Math.PI) / 4;
+  //TODO: elements with curve outside minMax points have a wrong bounding box
+  it.skip("flips a rotated line horizontally with line outside min/max points bounds", async () => {
+    const originalAngle = Math.PI / 4;
+    const expectedAngle = (7 * Math.PI) / 4;
+    const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
+    mutateElement(line, { angle: originalAngle });
+    h.app.scene.replaceAllElements([line]);
+    h.app.setState({ selectedElementIds: { [line.id]: true } });
+
+    await checkRotatedHorizontalFlip(
+      expectedAngle,
+      MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
+    );
+  });
 
-  createAndSelectOneLine(originalAngle);
+  //TODO: elements with curve outside minMax points have a wrong bounding box
+  it.skip("flips a rotated line vertically with line outside min/max points bounds", async () => {
+    const originalAngle = Math.PI / 4;
+    const expectedAngle = (3 * Math.PI) / 4;
+    const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
+    mutateElement(line, { angle: originalAngle });
+    h.app.scene.replaceAllElements([line]);
+    h.app.setState({ selectedElementIds: { [line.id]: true } });
+
+    await checkRotatedVerticalFlip(
+      expectedAngle,
+      MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
+    );
+  });
 
-  const originalWidth = API.getSelectedElements()[0].width;
-  const originalHeight = API.getSelectedElements()[0].height;
+  it("flips an unrotated line vertically correctly", async () => {
+    createAndSelectOneLine();
+    await checkVerticalFlip(TWO_POINTS_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS);
+  });
 
-  h.app.actionManager.executeAction(actionFlipVertical);
+  it("flips a rotated line horizontally with line inside min/max points bounds", async () => {
+    const originalAngle = Math.PI / 4;
+    const expectedAngle = (7 * Math.PI) / 4;
+    const line = createLinearElementWithCurveInsideMinMaxPoints("line");
+    h.app.scene.replaceAllElements([line]);
+    h.app.state.selectedElementIds[line.id] = true;
+    mutateElement(line, {
+      angle: originalAngle,
+    });
+
+    await checkRotatedHorizontalFlip(
+      expectedAngle,
+      MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
+    );
+  });
 
-  // Check if width and height did not change
-  expect(API.getSelectedElements()[0].width).toBeCloseTo(
-    originalWidth,
-    FLIP_PRECISION_DECIMALS,
-  );
+  it("flips a rotated line vertically with line inside min/max points bounds", async () => {
+    const originalAngle = Math.PI / 4;
+    const expectedAngle = (3 * Math.PI) / 4;
+    const line = createLinearElementWithCurveInsideMinMaxPoints("line");
+    h.app.scene.replaceAllElements([line]);
+    h.app.state.selectedElementIds[line.id] = true;
+    mutateElement(line, {
+      angle: originalAngle,
+    });
+
+    await checkRotatedVerticalFlip(
+      expectedAngle,
+      MULTIPOINT_LINEAR_ELEMENT_FLIP_TOLERANCE_IN_PIXELS,
+    );
+  });
 
-  expect(API.getSelectedElements()[0].height).toBeCloseTo(
-    originalHeight,
-    FLIP_PRECISION_DECIMALS,
-  );
+  it("flips a two points line horizontally correctly", async () => {
+    createAndSelectOneLine();
+    await checkTwoPointsLineHorizontalFlip();
+  });
 
-  // Check angle
-  expect(API.getSelectedElements()[0].angle).toBeCloseTo(expectedAngle);
+  it("flips a two points line vertically correctly", async () => {
+    createAndSelectOneLine();
+    await checkTwoPointsLineVerticalFlip();
+  });
 });
 
 // Draw element
+describe("freedraw", () => {
+  it("flips an unrotated drawing horizontally correctly", async () => {
+    const draw = createAndReturnOneDraw();
+    // select draw, since not done automatically
+    h.state.selectedElementIds[draw.id] = true;
+    await checkHorizontalFlip();
+  });
 
-it("flips an unrotated drawing horizontally correctly", () => {
-  const draw = createAndReturnOneDraw();
-  // select draw, since not done automatically
-  h.state.selectedElementIds[draw.id] = true;
-
-  const originalWidth = draw.width;
-  const originalHeight = draw.height;
-
-  h.app.actionManager.executeAction(actionFlipHorizontal);
-
-  // Check if width and height did not change
-  expect(draw.width).toBeCloseTo(originalWidth, FLIP_PRECISION_DECIMALS);
-
-  expect(draw.height).toBeCloseTo(originalHeight, FLIP_PRECISION_DECIMALS);
-});
-
-it("flips an unrotated drawing vertically correctly", () => {
-  const draw = createAndReturnOneDraw();
-  // select draw, since not done automatically
-  h.state.selectedElementIds[draw.id] = true;
-
-  const originalWidth = draw.width;
-  const originalHeight = draw.height;
-
-  h.app.actionManager.executeAction(actionFlipVertical);
-
-  // Check if width and height did not change
-  expect(draw.width).toBeCloseTo(originalWidth, FLIP_PRECISION_DECIMALS);
-
-  expect(draw.height).toBeCloseTo(originalHeight, FLIP_PRECISION_DECIMALS);
-});
-
-it("flips a rotated drawing horizontally correctly", () => {
-  const originalAngle = Math.PI / 4;
-  const expectedAngle = (7 * Math.PI) / 4;
+  it("flips an unrotated drawing vertically correctly", async () => {
+    const draw = createAndReturnOneDraw();
+    // select draw, since not done automatically
+    h.state.selectedElementIds[draw.id] = true;
+    await checkVerticalFlip();
+  });
 
-  const draw = createAndReturnOneDraw(originalAngle);
-  // select draw, since not done automatically
-  h.state.selectedElementIds[draw.id] = true;
+  it("flips a rotated drawing horizontally correctly", async () => {
+    const originalAngle = Math.PI / 4;
+    const expectedAngle = (7 * Math.PI) / 4;
 
-  const originalWidth = draw.width;
-  const originalHeight = draw.height;
+    const draw = createAndReturnOneDraw(originalAngle);
+    // select draw, since not done automatically
+    h.state.selectedElementIds[draw.id] = true;
 
-  h.app.actionManager.executeAction(actionFlipHorizontal);
+    await checkRotatedHorizontalFlip(expectedAngle);
+  });
 
-  // Check if width and height did not change
-  expect(draw.width).toBeCloseTo(originalWidth, FLIP_PRECISION_DECIMALS);
+  it("flips a rotated drawing vertically correctly", async () => {
+    const originalAngle = Math.PI / 4;
+    const expectedAngle = (3 * Math.PI) / 4;
 
-  expect(draw.height).toBeCloseTo(originalHeight, FLIP_PRECISION_DECIMALS);
+    const draw = createAndReturnOneDraw(originalAngle);
+    // select draw, since not done automatically
+    h.state.selectedElementIds[draw.id] = true;
 
-  // Check angle
-  expect(draw.angle).toBeCloseTo(expectedAngle);
+    await checkRotatedVerticalFlip(expectedAngle);
+  });
 });
 
-it("flips a rotated drawing vertically correctly", () => {
-  const originalAngle = Math.PI / 4;
-  const expectedAngle = (3 * Math.PI) / 4;
-
-  const draw = createAndReturnOneDraw(originalAngle);
-  // select draw, since not done automatically
-  h.state.selectedElementIds[draw.id] = true;
-
-  const originalWidth = draw.width;
-  const originalHeight = draw.height;
-
-  h.app.actionManager.executeAction(actionFlipVertical);
+//image
+//TODO: currently there is no test for pixel colors at flipped positions.
+describe("image", () => {
+  const createImage = async () => {
+    const sendPasteEvent = (file?: File) => {
+      const clipboardEvent = new Event("paste", {
+        bubbles: true,
+        cancelable: true,
+        composed: true,
+      });
+
+      // set `clipboardData` properties.
+      // @ts-ignore
+      clipboardEvent.clipboardData = {
+        getData: () => window.navigator.clipboard.readText(),
+        files: [file],
+      };
+
+      document.dispatchEvent(clipboardEvent);
+    };
+
+    sendPasteEvent(await API.loadFile("./fixtures/smiley_embedded_v2.png"));
+  };
+
+  it("flips an unrotated image horizontally correctly", async () => {
+    //paste image
+    await createImage();
+
+    await waitFor(() => {
+      expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([1, 1]);
+      expect(API.getSelectedElements().length).toBeGreaterThan(0);
+      expect(API.getSelectedElements()[0].type).toEqual("image");
+      expect(h.app.files.fileId).toBeDefined();
+    });
+    await checkHorizontalFlip();
+    expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([-1, 1]);
+    expect(h.elements[0].angle).toBeCloseTo(0);
+  });
 
-  // Check if width and height did not change
+  it("flips an unrotated image vertically correctly", async () => {
+    //paste image
+    await createImage();
+    await waitFor(() => {
+      expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([1, 1]);
+      expect(API.getSelectedElements().length).toBeGreaterThan(0);
+      expect(API.getSelectedElements()[0].type).toEqual("image");
+      expect(h.app.files.fileId).toBeDefined();
+    });
+
+    await checkVerticalFlip();
+    expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([-1, 1]);
+    expect(h.elements[0].angle).toBeCloseTo(Math.PI);
+  });
 
-  expect(API.getSelectedElement().width).toBeCloseTo(
-    originalWidth,
-    FLIP_PRECISION_DECIMALS,
-  );
+  it("flips an rotated image horizontally correctly", async () => {
+    const originalAngle = Math.PI / 4;
+    const expectedAngle = (7 * Math.PI) / 4;
+    //paste image
+    await createImage();
+    await waitFor(() => {
+      expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([1, 1]);
+      expect(API.getSelectedElements().length).toBeGreaterThan(0);
+      expect(API.getSelectedElements()[0].type).toEqual("image");
+      expect(h.app.files.fileId).toBeDefined();
+    });
+    mutateElement(h.elements[0], {
+      angle: originalAngle,
+    });
+    await checkRotatedHorizontalFlip(expectedAngle);
+    expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([-1, 1]);
+  });
 
-  expect(API.getSelectedElement().height).toBeCloseTo(
-    originalHeight,
-    FLIP_PRECISION_DECIMALS,
-  );
+  it("flips an rotated image vertically correctly", async () => {
+    const originalAngle = Math.PI / 4;
+    const expectedAngle = (3 * Math.PI) / 4;
+    //paste image
+    await createImage();
+    await waitFor(() => {
+      expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([1, 1]);
+      expect(h.elements[0].angle).toEqual(0);
+      expect(API.getSelectedElements().length).toBeGreaterThan(0);
+      expect(API.getSelectedElements()[0].type).toEqual("image");
+      expect(h.app.files.fileId).toBeDefined();
+    });
+    mutateElement(h.elements[0], {
+      angle: originalAngle,
+    });
+
+    await checkRotatedVerticalFlip(expectedAngle);
+    expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([-1, 1]);
+    expect(h.elements[0].angle).toBeCloseTo(expectedAngle);
+  });
 
-  // Check angle
-  expect(API.getSelectedElement().angle).toBeCloseTo(expectedAngle);
+  it("flips an image both vertically & horizontally", async () => {
+    //paste image
+    await createImage();
+    await waitFor(() => {
+      expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([1, 1]);
+      expect(API.getSelectedElements().length).toBeGreaterThan(0);
+      expect(API.getSelectedElements()[0].type).toEqual("image");
+      expect(h.app.files.fileId).toBeDefined();
+    });
+
+    await checkVerticalHorizontalFlip();
+    expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([1, 1]);
+    expect(h.elements[0].angle).toBeCloseTo(Math.PI);
+  });
 });

+ 49 - 21
src/types.ts

@@ -287,7 +287,6 @@ export interface ExcalidrawProps {
     | null
     | Promise<ExcalidrawInitialDataState | null>;
   excalidrawRef?: ForwardRef<ExcalidrawAPIRefValue>;
-  onCollabButtonClick?: () => void;
   isCollaborating?: boolean;
   onPointerUpdate?: (payload: {
     pointer: { x: number; y: number };
@@ -313,10 +312,7 @@ export interface ExcalidrawProps {
     elements: readonly NonDeletedExcalidrawElement[],
     appState: AppState,
   ) => JSX.Element;
-  UIOptions?: {
-    dockedSidebarBreakpoint?: number;
-    canvasActions?: CanvasActions;
-  };
+  UIOptions?: Partial<UIOptions>;
   detectScroll?: boolean;
   handleKeyboardGlobally?: boolean;
   onLibraryChange?: (libraryItems: LibraryItems) => void | Promise<any>;
@@ -373,23 +369,31 @@ export type ExportOpts = {
 // truthiness value will determine whether the action is rendered or not
 // (see manager renderAction). We also override canvasAction values in
 // excalidraw package index.tsx.
-type CanvasActions = {
-  changeViewBackgroundColor?: boolean;
-  clearCanvas?: boolean;
-  export?: false | ExportOpts;
-  loadScene?: boolean;
-  saveToActiveFile?: boolean;
-  toggleTheme?: boolean | null;
-  saveAsImage?: boolean;
-};
+type CanvasActions = Partial<{
+  changeViewBackgroundColor: boolean;
+  clearCanvas: boolean;
+  export: false | ExportOpts;
+  loadScene: boolean;
+  saveToActiveFile: boolean;
+  toggleTheme: boolean | null;
+  saveAsImage: boolean;
+}>;
+
+type UIOptions = Partial<{
+  dockedSidebarBreakpoint: number;
+  welcomeScreen: boolean;
+  canvasActions: CanvasActions;
+}>;
 
 export type AppProps = Merge<
   ExcalidrawProps,
   {
-    UIOptions: {
-      canvasActions: Required<CanvasActions> & { export: ExportOpts };
-      dockedSidebarBreakpoint?: number;
-    };
+    UIOptions: Merge<
+      MarkRequired<UIOptions, "welcomeScreen">,
+      {
+        canvasActions: Required<CanvasActions> & { export: ExportOpts };
+      }
+    >;
     detectScroll: boolean;
     handleKeyboardGlobally: boolean;
     isCollaborating: boolean;
@@ -518,7 +522,31 @@ export type Device = Readonly<{
 }>;
 
 export type UIChildrenComponents = {
-  [k in "FooterCenter" | "Menu"]?:
-    | React.ReactPortal
-    | React.ReactElement<unknown, string | React.JSXElementConstructor<any>>;
+  [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>
+  >;
 };

+ 50 - 20
src/utils.ts

@@ -352,9 +352,8 @@ export const viewportCoordsToSceneCoords = (
     scrollY: number;
   },
 ) => {
-  const invScale = 1 / zoom.value;
-  const x = (clientX - offsetLeft) * invScale - scrollX;
-  const y = (clientY - offsetTop) * invScale - scrollY;
+  const x = (clientX - offsetLeft) / zoom.value - scrollX;
+  const y = (clientY - offsetTop) / zoom.value - scrollY;
 
   return { x, y };
 };
@@ -688,25 +687,56 @@ export const queryFocusableElements = (container: HTMLElement | null) => {
     : [];
 };
 
-export const ReactChildrenToObject = <
-  T extends {
-    [k in string]?:
-      | React.ReactPortal
-      | React.ReactElement<unknown, string | React.JSXElementConstructor<any>>;
+/**
+ * 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>,
 ) => {
-  return React.Children.toArray(children).reduce((acc, child) => {
-    if (
-      React.isValidElement(child) &&
-      typeof child.type !== "string" &&
-      //@ts-ignore
-      child?.type.displayName
-    ) {
-      // @ts-ignore
-      acc[child.type.displayName] = child;
-    }
-    return acc;
-  }, {} as Partial<T>);
+  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,
+) => {
+  const aKeys = Object.keys(objA);
+  const bKeys = Object.keys(objA);
+  if (aKeys.length !== bKeys.length) {
+    return false;
+  }
+  return aKeys.every((key) => objA[key] === objB[key]);
 };