123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512 |
- import clsx from "clsx";
- import React from "react";
- import { ActionManager } from "../actions/manager";
- import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants";
- import { exportCanvas } from "../data";
- import { isTextElement, showSelectedShapeActions } from "../element";
- import { NonDeletedExcalidrawElement } from "../element/types";
- import { Language, t } from "../i18n";
- import { calculateScrollCenter } from "../scene";
- import { ExportType } from "../scene/types";
- import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
- import { isShallowEqual, muteFSAbortError } from "../utils";
- import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
- import { ErrorDialog } from "./ErrorDialog";
- import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
- import { FixedSideContainer } from "./FixedSideContainer";
- import { HintViewer } from "./HintViewer";
- import { Island } from "./Island";
- import { LoadingMessage } from "./LoadingMessage";
- import { LockButton } from "./LockButton";
- import { MobileMenu } from "./MobileMenu";
- import { PasteChartDialog } from "./PasteChartDialog";
- import { Section } from "./Section";
- import { HelpDialog } from "./HelpDialog";
- import Stack from "./Stack";
- import { UserList } from "./UserList";
- import Library from "../data/library";
- import { JSONExportDialog } from "./JSONExportDialog";
- import { LibraryButton } from "./LibraryButton";
- import { isImageFileHandle } from "../data/blob";
- import { LibraryMenu } from "./LibraryMenu";
- import "./LayerUI.scss";
- import "./Toolbar.scss";
- import { PenModeButton } from "./PenModeButton";
- import { trackEvent } from "../analytics";
- import { useDevice } from "../components/App";
- import { Stats } from "./Stats";
- import { actionToggleStats } from "../actions/actionToggleStats";
- import Footer from "./footer/Footer";
- import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
- import { jotaiScope } from "../jotai";
- import { useAtom } from "jotai";
- import MainMenu from "./main-menu/MainMenu";
- import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
- import { HandButton } from "./HandButton";
- import { isHandToolActive } from "../appState";
- import {
- mainMenuTunnel,
- welcomeScreenMenuHintTunnel,
- welcomeScreenToolbarHintTunnel,
- welcomeScreenCenterTunnel,
- } from "./tunnels";
- interface LayerUIProps {
- actionManager: ActionManager;
- appState: AppState;
- files: BinaryFiles;
- canvas: HTMLCanvasElement | null;
- setAppState: React.Component<any, AppState>["setState"];
- elements: readonly NonDeletedExcalidrawElement[];
- onLockToggle: () => void;
- onHandToolToggle: () => void;
- onPenModeToggle: () => void;
- onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
- showExitZenModeBtn: boolean;
- langCode: Language["code"];
- renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
- renderCustomStats?: ExcalidrawProps["renderCustomStats"];
- renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
- libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
- UIOptions: AppProps["UIOptions"];
- focusContainer: () => void;
- library: Library;
- id: string;
- onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
- renderWelcomeScreen: boolean;
- children?: React.ReactNode;
- }
- const DefaultMainMenu: React.FC<{
- UIOptions: AppProps["UIOptions"];
- }> = ({ UIOptions }) => {
- return (
- <MainMenu __fallback>
- <MainMenu.DefaultItems.LoadScene />
- <MainMenu.DefaultItems.SaveToActiveFile />
- {/* FIXME we should to test for this inside the item itself */}
- {UIOptions.canvasActions.export && <MainMenu.DefaultItems.Export />}
- {/* FIXME we should to test for this inside the item itself */}
- {UIOptions.canvasActions.saveAsImage && (
- <MainMenu.DefaultItems.SaveAsImage />
- )}
- <MainMenu.DefaultItems.Help />
- <MainMenu.DefaultItems.ClearCanvas />
- <MainMenu.Separator />
- <MainMenu.Group title="Excalidraw links">
- <MainMenu.DefaultItems.Socials />
- </MainMenu.Group>
- <MainMenu.Separator />
- <MainMenu.DefaultItems.ToggleTheme />
- <MainMenu.DefaultItems.ChangeCanvasBackground />
- </MainMenu>
- );
- };
- const LayerUI = ({
- actionManager,
- appState,
- files,
- setAppState,
- elements,
- canvas,
- onLockToggle,
- onHandToolToggle,
- onPenModeToggle,
- onInsertElements,
- showExitZenModeBtn,
- renderTopRightUI,
- renderCustomStats,
- renderCustomSidebar,
- libraryReturnUrl,
- UIOptions,
- focusContainer,
- library,
- id,
- onImageAction,
- renderWelcomeScreen,
- children,
- }: LayerUIProps) => {
- const device = useDevice();
- const renderJSONExportDialog = () => {
- if (!UIOptions.canvasActions.export) {
- return null;
- }
- return (
- <JSONExportDialog
- elements={elements}
- appState={appState}
- files={files}
- actionManager={actionManager}
- exportOpts={UIOptions.canvasActions.export}
- canvas={canvas}
- setAppState={setAppState}
- />
- );
- };
- const renderImageExportDialog = () => {
- if (!UIOptions.canvasActions.saveAsImage) {
- return null;
- }
- const createExporter =
- (type: ExportType): ExportCB =>
- async (exportedElements) => {
- trackEvent("export", type, "ui");
- const fileHandle = await exportCanvas(
- type,
- exportedElements,
- appState,
- files,
- {
- exportBackground: appState.exportBackground,
- name: appState.name,
- viewBackgroundColor: appState.viewBackgroundColor,
- },
- )
- .catch(muteFSAbortError)
- .catch((error) => {
- console.error(error);
- setAppState({ errorMessage: error.message });
- });
- if (
- appState.exportEmbedScene &&
- fileHandle &&
- isImageFileHandle(fileHandle)
- ) {
- setAppState({ fileHandle });
- }
- };
- return (
- <ImageExportDialog
- elements={elements}
- appState={appState}
- setAppState={setAppState}
- files={files}
- actionManager={actionManager}
- onExportToPng={createExporter("png")}
- onExportToSvg={createExporter("svg")}
- onExportToClipboard={createExporter("clipboard")}
- />
- );
- };
- const renderCanvasActions = () => (
- <div style={{ position: "relative" }}>
- {/* wrapping to Fragment stops React from occasionally complaining
- about identical Keys */}
- <mainMenuTunnel.Out />
- {renderWelcomeScreen && <welcomeScreenMenuHintTunnel.Out />}
- </div>
- );
- const renderSelectedShapeActions = () => (
- <Section
- heading="selectedShapeActions"
- className={clsx("selected-shape-actions zen-mode-transition", {
- "transition-left": appState.zenModeEnabled,
- })}
- >
- <Island
- className={CLASSES.SHAPE_ACTIONS_MENU}
- padding={2}
- style={{
- // we want to make sure this doesn't overflow so subtracting the
- // approximate height of hamburgerMenu + footer
- maxHeight: `${appState.height - 166}px`,
- }}
- >
- <SelectedShapeActions
- appState={appState}
- elements={elements}
- renderAction={actionManager.renderAction}
- />
- </Island>
- </Section>
- );
- const renderFixedSideContainer = () => {
- const shouldRenderSelectedShapeActions = showSelectedShapeActions(
- appState,
- elements,
- );
- return (
- <FixedSideContainer side="top">
- <div className="App-menu App-menu_top">
- <Stack.Col
- gap={6}
- className={clsx("App-menu_top__left", {
- "disable-pointerEvents": appState.zenModeEnabled,
- })}
- >
- {renderCanvasActions()}
- {shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
- </Stack.Col>
- {!appState.viewModeEnabled && (
- <Section heading="shapes" className="shapes-section">
- {(heading: React.ReactNode) => (
- <div style={{ position: "relative" }}>
- {renderWelcomeScreen && (
- <welcomeScreenToolbarHintTunnel.Out />
- )}
- <Stack.Col gap={4} align="start">
- <Stack.Row
- gap={1}
- className={clsx("App-toolbar-container", {
- "zen-mode": appState.zenModeEnabled,
- })}
- >
- <Island
- padding={1}
- className={clsx("App-toolbar", {
- "zen-mode": appState.zenModeEnabled,
- })}
- >
- <HintViewer
- appState={appState}
- elements={elements}
- isMobile={device.isMobile}
- device={device}
- />
- {heading}
- <Stack.Row gap={1}>
- <PenModeButton
- zenModeEnabled={appState.zenModeEnabled}
- checked={appState.penMode}
- onChange={onPenModeToggle}
- title={t("toolBar.penMode")}
- penDetected={appState.penDetected}
- />
- <LockButton
- checked={appState.activeTool.locked}
- onChange={onLockToggle}
- title={t("toolBar.lock")}
- />
- <div className="App-toolbar__divider"></div>
- <HandButton
- checked={isHandToolActive(appState)}
- onChange={() => onHandToolToggle()}
- title={t("toolBar.hand")}
- isMobile
- />
- <ShapesSwitcher
- appState={appState}
- canvas={canvas}
- activeTool={appState.activeTool}
- setAppState={setAppState}
- onImageAction={({ pointerType }) => {
- onImageAction({
- insertOnCanvasDirectly: pointerType !== "mouse",
- });
- }}
- />
- </Stack.Row>
- </Island>
- </Stack.Row>
- </Stack.Col>
- </div>
- )}
- </Section>
- )}
- <div
- className={clsx(
- "layer-ui__wrapper__top-right zen-mode-transition",
- {
- "transition-right": appState.zenModeEnabled,
- },
- )}
- >
- <UserList collaborators={appState.collaborators} />
- {renderTopRightUI?.(device.isMobile, appState)}
- {!appState.viewModeEnabled && (
- <LibraryButton appState={appState} setAppState={setAppState} />
- )}
- </div>
- </div>
- </FixedSideContainer>
- );
- };
- const renderSidebars = () => {
- return appState.openSidebar === "customSidebar" ? (
- renderCustomSidebar?.() || null
- ) : appState.openSidebar === "library" ? (
- <LibraryMenu
- appState={appState}
- onInsertElements={onInsertElements}
- libraryReturnUrl={libraryReturnUrl}
- focusContainer={focusContainer}
- library={library}
- id={id}
- />
- ) : null;
- };
- const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope);
- return (
- <>
- {/* ------------------------- tunneled UI ---------------------------- */}
- {/* make sure we render host app components first so that we can detect
- them first on initial render to optimize layout shift */}
- {children}
- {/* render component fallbacks. Can be rendered anywhere as they'll be
- tunneled away. We only render tunneled components that actually
- have defaults when host do not render anything. */}
- <DefaultMainMenu UIOptions={UIOptions} />
- {/* ------------------------------------------------------------------ */}
- {appState.isLoading && <LoadingMessage delay={250} />}
- {appState.errorMessage && (
- <ErrorDialog
- message={appState.errorMessage}
- onClose={() => setAppState({ errorMessage: null })}
- />
- )}
- {appState.openDialog === "help" && (
- <HelpDialog
- onClose={() => {
- setAppState({ openDialog: null });
- }}
- />
- )}
- <ActiveConfirmDialog />
- {renderImageExportDialog()}
- {renderJSONExportDialog()}
- {appState.pasteDialog.shown && (
- <PasteChartDialog
- setAppState={setAppState}
- appState={appState}
- onInsertChart={onInsertElements}
- onClose={() =>
- setAppState({
- pasteDialog: { shown: false, data: null },
- })
- }
- />
- )}
- {device.isMobile && (
- <MobileMenu
- appState={appState}
- elements={elements}
- actionManager={actionManager}
- renderJSONExportDialog={renderJSONExportDialog}
- renderImageExportDialog={renderImageExportDialog}
- setAppState={setAppState}
- onLockToggle={onLockToggle}
- onHandToolToggle={onHandToolToggle}
- onPenModeToggle={onPenModeToggle}
- canvas={canvas}
- onImageAction={onImageAction}
- renderTopRightUI={renderTopRightUI}
- renderCustomStats={renderCustomStats}
- renderSidebars={renderSidebars}
- device={device}
- />
- )}
- {!device.isMobile && (
- <>
- <div
- className={clsx("layer-ui__wrapper", {
- "disable-pointerEvents":
- appState.draggingElement ||
- appState.resizingElement ||
- (appState.editingElement &&
- !isTextElement(appState.editingElement)),
- })}
- style={
- ((appState.openSidebar === "library" &&
- appState.isSidebarDocked) ||
- hostSidebarCounters.docked) &&
- device.canDeviceFitSidebar
- ? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
- : {}
- }
- >
- {renderWelcomeScreen && <welcomeScreenCenterTunnel.Out />}
- {renderFixedSideContainer()}
- <Footer
- appState={appState}
- actionManager={actionManager}
- showExitZenModeBtn={showExitZenModeBtn}
- renderWelcomeScreen={renderWelcomeScreen}
- />
- {appState.showStats && (
- <Stats
- appState={appState}
- setAppState={setAppState}
- elements={elements}
- onClose={() => {
- actionManager.executeAction(actionToggleStats);
- }}
- renderCustomStats={renderCustomStats}
- />
- )}
- {appState.scrolledOutside && (
- <button
- className="scroll-back-to-content"
- onClick={() => {
- setAppState({
- ...calculateScrollCenter(elements, appState, canvas),
- });
- }}
- >
- {t("buttons.scrollBackToContent")}
- </button>
- )}
- </div>
- {renderSidebars()}
- </>
- )}
- </>
- );
- };
- const stripIrrelevantAppStateProps = (
- appState: AppState,
- ): Partial<AppState> => {
- const { suggestedBindings, startBoundElement, cursorButton, ...ret } =
- appState;
- return ret;
- };
- const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
- // short-circuit early
- if (prevProps.children !== nextProps.children) {
- return false;
- }
- const {
- 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 (
- isShallowEqual(
- stripIrrelevantAppStateProps(prevAppState),
- stripIrrelevantAppStateProps(nextAppState),
- ) && isShallowEqual(prev, next)
- );
- };
- export default React.memo(LayerUI, areEqual);
|