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

This commit is contained in:
Daniel J. Geiger 2023-01-19 18:32:16 -06:00
commit e385066b4b
47 changed files with 1884 additions and 1150 deletions

View File

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

View File

@ -283,15 +283,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[]
@ -309,7 +306,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>(
@ -317,6 +316,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 = () =>
@ -539,8 +541,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
@ -574,7 +575,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) =>
@ -601,6 +601,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}
onImageAction={onImageAction} onImageAction={onImageAction}
renderTopRightUI={renderTopRightUI} renderTopRightUI={renderTopRightUI}
renderCustomStats={renderCustomStats} renderCustomStats={renderCustomStats}
@ -438,6 +421,7 @@ const LayerUI = ({
device={device} device={device}
renderMenu={renderMenu} renderMenu={renderMenu}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
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 return ret;
} = appState; };
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,11 +30,9 @@ 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;
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
renderTopRightUI?: ( renderTopRightUI?: (
@ -40,9 +42,9 @@ 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;
onContextMenu?: (event: React.MouseEvent, source: string) => void; onContextMenu?: (event: React.MouseEvent, source: string) => void;
welcomeScreenCenter: UIWelcomeScreenComponents["Center"];
}; };
export const MobileMenu = ({ export const MobileMenu = ({
@ -53,22 +55,19 @@ export const MobileMenu = ({
onLockToggle, onLockToggle,
onPenModeToggle, onPenModeToggle,
canvas, canvas,
isCollaborating,
onImageAction, onImageAction,
renderTopRightUI, renderTopRightUI,
renderCustomStats, renderCustomStats,
renderSidebars, renderSidebars,
device, device,
renderWelcomeScreen,
renderMenu, renderMenu,
onContextMenu, onContextMenu,
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">
@ -76,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}
@ -112,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 { &.theme--dark {
font-size: 1.125rem; .welcome-screen-decor {
text-align: center; color: var(--color-gray-60);
}
}
// WelcomeScreen.Hints
// ---------------------------------------------------------------------------
.welcome-screen-decor-hint {
@media (max-height: 599px) {
display: none !important;
} }
&--help-pointer { @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

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

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";
@ -26,6 +26,8 @@ import {
defaultLang, defaultLang,
Footer, Footer,
MainMenu, MainMenu,
LiveCollaborationTrigger,
WelcomeScreen,
} from "../packages/excalidraw/index"; } from "../packages/excalidraw/index";
import { import {
AppState, AppState,
@ -45,6 +47,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";
@ -608,7 +611,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)}
/> />
@ -634,6 +637,63 @@ const ExcalidrawWrapper = () => {
); );
}; };
const welcomeScreenJSX = useMemo(() => {
let headingContent;
if (isExcalidrawPlusSignedUser) {
headingContent = t("welcomeScreen.app.center_heading_plus")
.split(/(Excalidraw\+)/)
.map((bit, idx) => {
if (bit === "Excalidraw+") {
return (
<a
style={{ pointerEvents: "all" }}
href={`${process.env.REACT_APP_PLUS_APP}?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenSignedInUser`}
key={idx}
>
Excalidraw+
</a>
);
}
return bit;
});
} else {
headingContent = t("welcomeScreen.app.center_heading");
}
return (
<WelcomeScreen>
<WelcomeScreen.Hints.MenuHint>
{t("welcomeScreen.app.menuHint")}
</WelcomeScreen.Hints.MenuHint>
<WelcomeScreen.Hints.ToolbarHint />
<WelcomeScreen.Hints.HelpHint />
<WelcomeScreen.Center>
<WelcomeScreen.Center.Logo />
<WelcomeScreen.Center.Heading>
{headingContent}
</WelcomeScreen.Center.Heading>
<WelcomeScreen.Center.Menu>
<WelcomeScreen.Center.MenuItemLoadScene />
<WelcomeScreen.Center.MenuItemHelp />
<WelcomeScreen.Center.MenuItemLiveCollaborationTrigger
onSelect={() => setCollabDialogShown(true)}
/>
{!isExcalidrawPlusSignedUser && (
<WelcomeScreen.Center.MenuItemLink
href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
shortcut={null}
icon={PlusPromoIcon}
>
Try Excalidraw Plus!
</WelcomeScreen.Center.MenuItemLink>
)}
</WelcomeScreen.Center.Menu>
</WelcomeScreen.Center>
</WelcomeScreen>
);
}, [setCollabDialogShown]);
return ( return (
<div <div
style={{ height: "100%" }} style={{ height: "100%" }}
@ -645,7 +705,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={{
@ -679,14 +738,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

@ -448,10 +448,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

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

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,
@ -376,7 +372,7 @@ Most notably, you can customize the primary colors, by overriding these variable
For a complete list of variables, check [theme.scss](https://github.com/excalidraw/excalidraw/blob/master/src/css/theme.scss), though most of them will not make sense to override. For a complete list of variables, check [theme.scss](https://github.com/excalidraw/excalidraw/blob/master/src/css/theme.scss), though most of them will not make sense to override.
### Does this package support collaboration ? ### Does this package support collaboration?
No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/src/excalidraw-app/index.tsx). No, Excalidraw package doesn't come with collaboration built in, since the implementation is specific to each host app. We expose APIs which you can use to communicate with Excalidraw which you can use to implement it. You can check our own implementation [here](https://github.com/excalidraw/excalidraw/blob/master/src/excalidraw-app/index.tsx).
@ -405,45 +401,47 @@ const App = () => {
}; };
``` ```
This will only for `Desktop` devices. Footer is only rendered in the desktop view.
For `mobile` you will need to render it inside the [MainMenu](#mainmenu). You can use the [`useDevice`](#useDevice) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component. In the mobile view you can render it inside the [MainMenu](#mainmenu) (later we will expose other ways to customize the UI). You can use the [`useDevice`](#useDevice) hook to check the type of device, this will be available only inside the `children` of `Excalidraw` component.
```js ```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 (
<Footer> <Footer>
<button <button
className="custom-footer" className="custom-footer"
onClick={() => alert("This is custom footer in mobile menu")} onClick={() => alert("This is custom footer in mobile menu")}
> >
{" "} {" "}
custom footer{" "} custom footer{" "}
</button> </button>
</Footer> </Footer>
); );
} }
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
<MobileFooter/> </MainMenu.Item>
<MainMenu.Item onSelect={() => window.alert("Item2")}>
Item2
</MainMenu.Item>
<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.
#### MainMenu #### MainMenu
@ -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

@ -86,24 +86,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 dummy top right UI")}
style={{ height: "2.5rem" }}
>
{" "}
Click me{" "}
</button>
);
};
export default function App() { export default function App() {
const appRef = useRef<any>(null); const appRef = useRef<any>(null);
const [viewModeEnabled, setViewModeEnabled] = useState(false); const [viewModeEnabled, setViewModeEnabled] = useState(false);
@ -164,6 +153,28 @@ export default function App() {
fetchData(); fetchData();
}, [excalidrawAPI]); }, [excalidrawAPI]);
const renderTopRightUI = (isMobile: boolean) => {
return (
<>
{!isMobile && (
<LiveCollaborationTrigger
isCollaborating={isCollaborating}
onSelect={() => {
window.alert("Collab dialog clicked");
}}
/>
)}
<button
onClick={() => alert("This is dummy top right UI")}
style={{ height: "2.5rem" }}
>
{" "}
Click me{" "}
</button>
</>
);
};
const loadSceneOrLibrary = async () => { const 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);
@ -505,12 +516,10 @@ export default function App() {
<MainMenu.DefaultItems.SaveAsImage /> <MainMenu.DefaultItems.SaveAsImage />
<MainMenu.DefaultItems.Export /> <MainMenu.DefaultItems.Export />
<MainMenu.Separator /> <MainMenu.Separator />
{isCollaborating && ( <MainMenu.DefaultItems.LiveCollaborationTrigger
<MainMenu.DefaultItems.LiveCollaboration isCollaborating={isCollaborating}
onSelect={() => window.alert("You clicked on collab button")} onSelect={() => window.alert("You clicked on collab button")}
isCollaborating={isCollaborating} />
/>
)}
<MainMenu.Group title="Excalidraw links"> <MainMenu.Group title="Excalidraw links">
<MainMenu.DefaultItems.Socials /> <MainMenu.DefaultItems.Socials />
</MainMenu.Group> </MainMenu.Group>
@ -524,6 +533,7 @@ export default function App() {
</button> </button>
</MainMenu.ItemCustom> </MainMenu.ItemCustom>
<MainMenu.DefaultItems.Help /> <MainMenu.DefaultItems.Help />
{excalidrawAPI && <MobileFooter excalidrawAPI={excalidrawAPI} />} {excalidrawAPI && <MobileFooter excalidrawAPI={excalidrawAPI} />}
</MainMenu> </MainMenu>
); );
@ -693,9 +703,6 @@ export default function App() {
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 };

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -287,7 +287,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 };
@ -313,10 +312,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>;
@ -373,23 +369,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<
canvasActions: Required<CanvasActions> & { export: ExportOpts }; MarkRequired<UIOptions, "welcomeScreen">,
dockedSidebarBreakpoint?: number; {
}; canvasActions: Required<CanvasActions> & { export: ExportOpts };
}
>;
detectScroll: boolean; detectScroll: boolean;
handleKeyboardGlobally: boolean; handleKeyboardGlobally: boolean;
isCollaborating: boolean; isCollaborating: boolean;
@ -518,7 +522,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

@ -352,9 +352,8 @@ export const viewportCoordsToSceneCoords = (
scrollY: number; scrollY: number;
}, },
) => { ) => {
const invScale = 1 / zoom.value; const x = (clientX - offsetLeft) / zoom.value - scrollX;
const x = (clientX - offsetLeft) * invScale - scrollX; const y = (clientY - offsetTop) / zoom.value - scrollY;
const y = (clientY - offsetTop) * invScale - scrollY;
return { x, y }; return { x, y };
}; };
@ -688,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[] = [];
if (
React.isValidElement(child) && const knownChildren = React.Children.toArray(children).reduce(
typeof child.type !== "string" && (acc, child) => {
//@ts-ignore if (
child?.type.displayName React.isValidElement(child) &&
) { (!expectedComponents ||
// @ts-ignore ((child.type as any).displayName as string) in expectedComponents)
acc[child.type.displayName] = child; ) {
} // @ts-ignore
return acc; acc[child.type.displayName] = child;
}, {} as Partial<T>); } else {
restChildren.push(child);
}
return acc;
},
{} as Partial<KnownChildren>,
);
return [knownChildren, restChildren] as const;
};
export const isShallowEqual = <T extends Record<string, any>>(
objA: T,
objB: T,
) => {
const aKeys = Object.keys(objA);
const bKeys = Object.keys(objA);
if (aKeys.length !== bKeys.length) {
return false;
}
return aKeys.every((key) => objA[key] === objB[key]);
}; };