Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax

This commit is contained in:
Daniel J. Geiger 2023-01-17 15:12:39 -06:00
commit 59e74f94e6
41 changed files with 1066 additions and 612 deletions

View File

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

View File

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

35
src/components/Button.tsx Normal file
View File

@ -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>
);
};

View File

@ -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;

View File

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

View File

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

View File

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

View File

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

View File

@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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";

View File

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

View File

@ -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 };

View File

@ -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 };

View File

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

View File

@ -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;

View File

@ -150,6 +150,7 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
toggleTheme: null, toggleTheme: null,
saveAsImage: true, saveAsImage: true,
}, },
welcomeScreen: true,
}; };
// breakpoints // breakpoints
@ -236,14 +237,6 @@ export const ROUNDNESS = {
ADAPTIVE_RADIUS: 3, ADAPTIVE_RADIUS: 3,
} as const; } as const;
export const COOKIES = {
AUTH_STATE_COOKIE: "excplus-auth",
} as const;
/** key containt id of precedeing elemnt id we use in reconciliation during /** key containt id of precedeing elemnt id we use in reconciliation during
* collaboration */ * collaboration */
export const PRECEDING_ELEMENT_KEY = "__precedingElement__"; export const PRECEDING_ELEMENT_KEY = "__precedingElement__";
export const isExcalidrawPlusSignedUser = document.cookie.includes(
COOKIES.AUTH_STATE_COOKIE,
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,13 +15,22 @@ Please add the latest change on the top under the correct section.
### Features ### 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) - 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 ### Excalidraw schema

View File

@ -138,9 +138,6 @@ export default function App() {
console.log("Elements :", elements, "State : ", state) console.log("Elements :", elements, "State : ", state)
} }
onPointerUpdate={(payload) => console.log(payload)} onPointerUpdate={(payload) => console.log(payload)}
onCollabButtonClick={() =>
window.alert("You clicked on collab button")
}
viewModeEnabled={viewModeEnabled} viewModeEnabled={viewModeEnabled}
zenModeEnabled={zenModeEnabled} zenModeEnabled={zenModeEnabled}
gridModeEnabled={gridModeEnabled} gridModeEnabled={gridModeEnabled}
@ -331,7 +328,6 @@ const App = () => {
onChange: (elements, state) => onChange: (elements, state) =>
console.log("Elements :", elements, "State : ", state), console.log("Elements :", elements, "State : ", state),
onPointerUpdate: (payload) => console.log(payload), onPointerUpdate: (payload) => console.log(payload),
onCollabButtonClick: () => window.alert("You clicked on collab button"),
viewModeEnabled: viewModeEnabled, viewModeEnabled: viewModeEnabled,
zenModeEnabled: zenModeEnabled, zenModeEnabled: zenModeEnabled,
gridModeEnabled: gridModeEnabled, gridModeEnabled: gridModeEnabled,
@ -405,15 +401,14 @@ 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 ```js
import { useDevice, Footer } from "@excalidraw/excalidraw"; import { useDevice, Footer } from "@excalidraw/excalidraw";
const MobileFooter = ({ const MobileFooter = () => {
}) => {
const device = useDevice(); const device = useDevice();
if (device.isMobile) { if (device.isMobile) {
return ( return (
@ -429,18 +424,21 @@ const MobileFooter = ({
); );
} }
return null; return null;
}; };
const App = () => { const App = () => {
<Excalidraw> <Excalidraw>
<MainMenu> <MainMenu>
<MainMenu.Item onSelect={() => window.alert("Item1")}> Item1 </MainMenu.Item> <MainMenu.Item onSelect={() => window.alert("Item1")}>
<MainMenu.Item onSelect={() => window.alert("Item2")}> Item 2 </> Item1
</MainMenu.Item>
<MainMenu.Item onSelect={() => window.alert("Item2")}>
Item2
</MainMenu.Item>
<MobileFooter /> <MobileFooter />
</MainMenu> </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.
@ -456,11 +454,15 @@ import { MainMenu } from "@excalidraw/excalidraw";
const App = () => { const App = () => {
<Excalidraw> <Excalidraw>
<MainMenu> <MainMenu>
<MainMenu.Item onSelect={() => window.alert("Item1")}> Item1 </MainMenu.Item> <MainMenu.Item onSelect={() => window.alert("Item1")}>
<MainMenu.Item onSelect={() => window.alert("Item2")}> Item 2 </> Item1
</MainMenu.Item>
<MainMenu.Item onSelect={() => window.alert("Item2")}>
Item2
</MainMenu.Item>
</MainMenu> </MainMenu>
</Excalidraw> </Excalidraw>;
} };
``` ```
**MainMenu** **MainMenu**
@ -469,28 +471,24 @@ This is the `MainMenu` component which you need to import to render the menu wit
**MainMenu.Item** **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 | | Prop | Type | Required | Default | Description |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `onSelect` | `Function` | Yes | `undefined` | The handler is triggered when the item is selected. | | `onSelect` | `Function` | Yes | `undefined` | The handler is triggered when the item is selected. |
| `children` | `React.ReactNode` | Yes | `undefined` | The content of the menu item | | `children` | `React.ReactNode` | Yes | `undefined` | The content of the menu item |
| `icon` | `JSX.Element` | No | `undefined` | The icon used in 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 | | `shortcut` | `string` | No | | The keyboard shortcut (label-only, does not affect behavior) |
| `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. |
**MainMenu.ItemLink** **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** **Usage**
```js ```js
import { MainMenu } from "@excalidraw/excalidraw"; import { MainMenu } from "@excalidraw/excalidraw";
const App = () => { const App = () => (
<Excalidraw> <Excalidraw>
<MainMenu> <MainMenu>
<MainMenu.ItemLink href="https://google.com">Google</MainMenu.ItemLink> <MainMenu.ItemLink href="https://google.com">Google</MainMenu.ItemLink>
@ -499,7 +497,7 @@ const App = () => {
</MainMenu.ItemLink> </MainMenu.ItemLink>
</MainMenu> </MainMenu>
</Excalidraw>; </Excalidraw>;
}; );
``` ```
| Prop | Type | Required | Default | Description | | 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. | | `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 | | `children` | `React.ReactNode` | Yes | `undefined` | The content of the menu item |
| `icon` | `JSX.Element` | No | `undefined` | The icon used in 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 | | `shortcut` | `string` | No | | The keyboard shortcut (label-only, does not affect behavior) |
| `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. |
**MainMenu.ItemCustom** **MainMenu.ItemCustom**
@ -521,7 +515,7 @@ To render a custom item, you can use `MainMenu.ItemCustom`.
```js ```js
import { MainMenu } from "@excalidraw/excalidraw"; import { MainMenu } from "@excalidraw/excalidraw";
const App = () => { const App = () => (
<Excalidraw> <Excalidraw>
<MainMenu> <MainMenu>
<MainMenu.ItemCustom> <MainMenu.ItemCustom>
@ -535,15 +529,12 @@ const App = () => {
</MainMenu.ItemCustom> </MainMenu.ItemCustom>
</MainMenu> </MainMenu>
</Excalidraw>; </Excalidraw>;
}; );
``` ```
| Prop | Type | Required | Default | Description | | Prop | Type | Required | Default | Description |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `children` | `React.ReactNode` | Yes | `undefined` | The content of the menu item | | `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** **MainMenu.DefaultItems**
@ -551,7 +542,7 @@ For the items which are shown in the menu in [excalidraw.com](https://excalidraw
```js ```js
import { MainMenu } from "@excalidraw/excalidraw"; import { MainMenu } from "@excalidraw/excalidraw";
const App = () => { const App = () => (
<Excalidraw> <Excalidraw>
<MainMenu> <MainMenu>
<MainMenu.DefaultItems.Socials/> <MainMenu.DefaultItems.Socials/>
@ -560,7 +551,7 @@ const App = () => {
<MainMenu.Item onSelect={() => window.alert("Item2")}> Item 2 </> <MainMenu.Item onSelect={() => window.alert("Item2")}> Item 2 </>
</MainMenu> </MainMenu>
</Excalidraw> </Excalidraw>
} )
``` ```
Here is a [complete list](https://github.com/excalidraw/excalidraw/blob/master/src/components/mainMenu/DefaultItems.tsx) of the default items. 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 ```js
import { MainMenu } from "@excalidraw/excalidraw"; import { MainMenu } from "@excalidraw/excalidraw";
const App = () => { const App = () => (
<Excalidraw> <Excalidraw>
<MainMenu> <MainMenu>
<MainMenu.Group title="Excalidraw items"> <MainMenu.Group title="Excalidraw items">
@ -584,15 +575,176 @@ const App = () => {
</MainMenu.Group> </MainMenu.Group>
</MainMenu> </MainMenu>
</Excalidraw> </Excalidraw>
} )
``` ```
| Prop | Type | Required | Default | Description | | Prop | Type | Required | Default | Description |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `children ` | `React.ReactNode` | Yes | `undefined` | The content of the `Menu Group` | | `children ` | `React.ReactNode` | Yes | `undefined` | The content of the `MainMenu.Group` |
| `title` | `string` | No | `undefined` | The `title` for the grouped items |
| `className` | `string` | No | "" | The `classname` to be added to the group | ### WelcomeScreen
| `style` | `React.CSsSProperties` | No | `undefined` | The inline `styles` to be added to the group |
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 ### 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. | | [`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. | | [`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 | | [`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 | | [`isCollaborating`](#isCollaborating) | `boolean` | | This implies if the app is in collaboration mode |
| [`onPointerUpdate`](#onPointerUpdate) | Function | | Callback triggered when mouse pointer is updated. | | [`onPointerUpdate`](#onPointerUpdate) | Function | | Callback triggered when mouse pointer is updated. |
| [`langCode`](#langCode) | string | `en` | Language code string | | [`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. 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` #### `isCollaborating`
This prop indicates if the app is in collaboration mode. 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 ```js
import { useDevice, Footer } from "@excalidraw/excalidraw"; import { useDevice, Footer } from "@excalidraw/excalidraw";
const MobileFooter = ({ const MobileFooter = () => {
}) => {
const device = useDevice(); const device = useDevice();
if (device.isMobile) { if (device.isMobile) {
return ( return (

View File

@ -72,24 +72,13 @@ const {
Sidebar, Sidebar,
Footer, Footer,
MainMenu, MainMenu,
LiveCollaborationTrigger,
} = window.ExcalidrawLib; } = window.ExcalidrawLib;
const COMMENT_ICON_DIMENSION = 32; const COMMENT_ICON_DIMENSION = 32;
const COMMENT_INPUT_HEIGHT = 50; const COMMENT_INPUT_HEIGHT = 50;
const COMMENT_INPUT_WIDTH = 150; const COMMENT_INPUT_WIDTH = 150;
const renderTopRightUI = () => {
return (
<button
onClick={() => alert("This is an empty top right UI")}
style={{ height: "2.5rem" }}
>
{" "}
Click me{" "}
</button>
);
};
export interface AppProps { export interface AppProps {
appTitle: string; appTitle: string;
useCustom: (api: ExcalidrawImperativeAPI | null, customArgs?: any[]) => void; useCustom: (api: ExcalidrawImperativeAPI | null, customArgs?: any[]) => void;
@ -156,6 +145,28 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
fetchData(); fetchData();
}, [excalidrawAPI]); }, [excalidrawAPI]);
const renderTopRightUI = (isMobile: boolean) => {
return (
<>
{!isMobile && (
<LiveCollaborationTrigger
isCollaborating={isCollaborating}
onSelect={() => {
window.alert("Collab dialog clicked");
}}
/>
)}
<button
onClick={() => alert("This is an empty top right UI")}
style={{ height: "2.5rem" }}
>
{" "}
Click me{" "}
</button>
</>
);
};
const loadSceneOrLibrary = async () => { const loadSceneOrLibrary = async () => {
const file = await fileOpen({ description: "Excalidraw or library file" }); const file = await fileOpen({ description: "Excalidraw or library file" });
const contents = await loadSceneOrLibraryFromBlob(file, null, null); const contents = await loadSceneOrLibraryFromBlob(file, null, null);
@ -497,12 +508,10 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
<MainMenu.DefaultItems.SaveAsImage /> <MainMenu.DefaultItems.SaveAsImage />
<MainMenu.DefaultItems.Export /> <MainMenu.DefaultItems.Export />
<MainMenu.Separator /> <MainMenu.Separator />
{isCollaborating && ( <MainMenu.DefaultItems.LiveCollaborationTrigger
<MainMenu.DefaultItems.LiveCollaboration
onSelect={() => window.alert("You clicked on collab button")}
isCollaborating={isCollaborating} isCollaborating={isCollaborating}
onSelect={() => window.alert("You clicked on collab button")}
/> />
)}
<MainMenu.Group title="Excalidraw links"> <MainMenu.Group title="Excalidraw links">
<MainMenu.DefaultItems.Socials /> <MainMenu.DefaultItems.Socials />
</MainMenu.Group> </MainMenu.Group>
@ -516,6 +525,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
</button> </button>
</MainMenu.ItemCustom> </MainMenu.ItemCustom>
<MainMenu.DefaultItems.Help /> <MainMenu.DefaultItems.Help />
{excalidrawAPI && <MobileFooter excalidrawAPI={excalidrawAPI} />} {excalidrawAPI && <MobileFooter excalidrawAPI={excalidrawAPI} />}
</MainMenu> </MainMenu>
); );
@ -685,9 +695,6 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
button: "down" | "up"; button: "down" | "up";
pointersMap: Gesture["pointers"]; pointersMap: Gesture["pointers"];
}) => setPointerData(payload)} }) => setPointerData(payload)}
onCollabButtonClick={() =>
window.alert("You clicked on collab button")
}
viewModeEnabled={viewModeEnabled} viewModeEnabled={viewModeEnabled}
zenModeEnabled={zenModeEnabled} zenModeEnabled={zenModeEnabled}
gridModeEnabled={gridModeEnabled} gridModeEnabled={gridModeEnabled}

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View File

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

View File

@ -687,25 +687,56 @@ export const queryFocusableElements = (container: HTMLElement | null) => {
: []; : [];
}; };
export const ReactChildrenToObject = < /**
T extends { * Partitions React children into named components and the rest of children.
[k in string]?: *
| React.ReactPortal * Returns known children as a dictionary of react children keyed by their
| React.ReactElement<unknown, string | React.JSXElementConstructor<any>>; * 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, children: React.ReactNode,
expectedComponents?: Record<keyof KnownChildren, any>,
) => { ) => {
return React.Children.toArray(children).reduce((acc, child) => { const restChildren: React.ReactNode[] = [];
const knownChildren = React.Children.toArray(children).reduce(
(acc, child) => {
if ( if (
React.isValidElement(child) && React.isValidElement(child) &&
typeof child.type !== "string" && (!expectedComponents ||
//@ts-ignore ((child.type as any).displayName as string) in expectedComponents)
child?.type.displayName
) { ) {
// @ts-ignore // @ts-ignore
acc[child.type.displayName] = child; acc[child.type.displayName] = child;
} else {
restChildren.push(child);
} }
return acc; return acc;
}, {} as Partial<T>); },
{} 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]);
}; };