From 6ec259a0e71174651bae95d4628138bf6fd68742 Mon Sep 17 00:00:00 2001 From: kj_sh604 Date: Sun, 15 Mar 2026 16:19:35 -0400 Subject: refactor: packages/ --- packages/excalidraw/components/LayerUI.tsx | 607 +++++++++++++++++++++++++++++ 1 file changed, 607 insertions(+) create mode 100644 packages/excalidraw/components/LayerUI.tsx (limited to 'packages/excalidraw/components/LayerUI.tsx') diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx new file mode 100644 index 0000000..11914d0 --- /dev/null +++ b/packages/excalidraw/components/LayerUI.tsx @@ -0,0 +1,607 @@ +import clsx from "clsx"; +import React from "react"; +import type { ActionManager } from "../actions/manager"; +import { CLASSES, DEFAULT_SIDEBAR, TOOL_TYPE } from "../constants"; +import { showSelectedShapeActions } from "../element"; +import type { NonDeletedExcalidrawElement } from "../element/types"; +import type { Language } from "../i18n"; +import { t } from "../i18n"; +import { calculateScrollCenter } from "../scene"; +import type { + AppProps, + AppState, + ExcalidrawProps, + BinaryFiles, + UIAppState, + AppClassProperties, +} from "../types"; +import { capitalizeString, isShallowEqual } from "../utils"; +import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; +import { ErrorDialog } from "./ErrorDialog"; +import { 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 { JSONExportDialog } from "./JSONExportDialog"; +import { PenModeButton } from "./PenModeButton"; +import { trackEvent } from "../analytics"; +import { useDevice } from "./App"; +import Footer from "./footer/Footer"; +import { isSidebarDockedAtom } from "./Sidebar/Sidebar"; +import { useAtom, useAtomValue } from "../editor-jotai"; +import MainMenu from "./main-menu/MainMenu"; +import { ActiveConfirmDialog } from "./ActiveConfirmDialog"; +import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm"; +import { HandButton } from "./HandButton"; +import { isHandToolActive } from "../appState"; +import { TunnelsContext, useInitializeTunnels } from "../context/tunnels"; +import { LibraryIcon } from "./icons"; +import { UIAppStateContext } from "../context/ui-appState"; +import { DefaultSidebar } from "./DefaultSidebar"; +import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper"; +import { mutateElement } from "../element/mutateElement"; +import { ShapeCache } from "../scene/ShapeCache"; +import Scene from "../scene/Scene"; +import { LaserPointerButton } from "./LaserPointerButton"; +import { TTDDialog } from "./TTDDialog/TTDDialog"; +import { Stats } from "./Stats"; +import { actionToggleStats } from "../actions"; +import ElementLinkDialog from "./ElementLinkDialog"; + +import "./LayerUI.scss"; +import "./Toolbar.scss"; + +interface LayerUIProps { + actionManager: ActionManager; + appState: UIAppState; + files: BinaryFiles; + canvas: HTMLCanvasElement; + setAppState: React.Component["setState"]; + elements: readonly NonDeletedExcalidrawElement[]; + onLockToggle: () => void; + onHandToolToggle: () => void; + onPenModeToggle: AppClassProperties["togglePenMode"]; + showExitZenModeBtn: boolean; + langCode: Language["code"]; + renderTopRightUI?: ExcalidrawProps["renderTopRightUI"]; + renderCustomStats?: ExcalidrawProps["renderCustomStats"]; + UIOptions: AppProps["UIOptions"]; + onExportImage: AppClassProperties["onExportImage"]; + renderWelcomeScreen: boolean; + children?: React.ReactNode; + app: AppClassProperties; + isCollaborating: boolean; + generateLinkForSelection?: AppProps["generateLinkForSelection"]; +} + +const DefaultMainMenu: React.FC<{ + UIOptions: AppProps["UIOptions"]; +}> = ({ UIOptions }) => { + return ( + + + + {/* FIXME we should to test for this inside the item itself */} + {UIOptions.canvasActions.export && } + {/* FIXME we should to test for this inside the item itself */} + {UIOptions.canvasActions.saveAsImage && ( + + )} + + + + + + + + + + + + ); +}; + +const DefaultOverwriteConfirmDialog = () => { + return ( + + + + + ); +}; + +const LayerUI = ({ + actionManager, + appState, + files, + setAppState, + elements, + canvas, + onLockToggle, + onHandToolToggle, + onPenModeToggle, + showExitZenModeBtn, + renderTopRightUI, + renderCustomStats, + UIOptions, + onExportImage, + renderWelcomeScreen, + children, + app, + isCollaborating, + generateLinkForSelection, +}: LayerUIProps) => { + const device = useDevice(); + const tunnels = useInitializeTunnels(); + + const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider; + + const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom); + + const renderJSONExportDialog = () => { + if (!UIOptions.canvasActions.export) { + return null; + } + + return ( + + ); + }; + + const renderImageExportDialog = () => { + if ( + !UIOptions.canvasActions.saveAsImage || + appState.openDialog?.name !== "imageExport" + ) { + return null; + } + + return ( + setAppState({ openDialog: null })} + name={app.getName()} + /> + ); + }; + + const renderCanvasActions = () => ( +
+ {/* wrapping to Fragment stops React from occasionally complaining + about identical Keys */} + + {renderWelcomeScreen && } +
+ ); + + const renderSelectedShapeActions = () => ( +
+ + + +
+ ); + + const renderFixedSideContainer = () => { + const shouldRenderSelectedShapeActions = showSelectedShapeActions( + appState, + elements, + ); + + const shouldShowStats = + appState.stats.open && + !appState.zenModeEnabled && + !appState.viewModeEnabled && + appState.openDialog?.name !== "elementLinkSelector"; + + return ( + +
+ + {renderCanvasActions()} + {shouldRenderSelectedShapeActions && renderSelectedShapeActions()} + + {!appState.viewModeEnabled && + appState.openDialog?.name !== "elementLinkSelector" && ( +
+ {(heading: React.ReactNode) => ( +
+ {renderWelcomeScreen && ( + + )} + + + + + {heading} + + onPenModeToggle(null)} + title={t("toolBar.penMode")} + penDetected={appState.penDetected} + /> + + +
+ + onHandToolToggle()} + title={t("toolBar.hand")} + isMobile + /> + + + + + {isCollaborating && ( + + + app.setActiveTool({ type: TOOL_TYPE.laser }) + } + isMobile + /> + + )} + + +
+ )} +
+ )} +
+ {appState.collaborators.size > 0 && ( + + )} + {renderTopRightUI?.(device.editor.isMobile, appState)} + {!appState.viewModeEnabled && + appState.openDialog?.name !== "elementLinkSelector" && + // hide button when sidebar docked + (!isSidebarDocked || + appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && ( + + )} + {shouldShowStats && ( + { + actionManager.executeAction(actionToggleStats); + }} + renderCustomStats={renderCustomStats} + /> + )} +
+
+
+ ); + }; + + const renderSidebars = () => { + return ( + { + trackEvent( + "sidebar", + `toggleDock (${docked ? "dock" : "undock"})`, + `(${device.editor.isMobile ? "mobile" : "desktop"})`, + ); + }} + /> + ); + }; + + const isSidebarDocked = useAtomValue(isSidebarDockedAtom); + + const layerUIJSX = ( + <> + {/* ------------------------- 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. */} + + { + if (open) { + trackEvent( + "sidebar", + `${DEFAULT_SIDEBAR.name} (open)`, + `button (${device.editor.isMobile ? "mobile" : "desktop"})`, + ); + } + }} + tab={DEFAULT_SIDEBAR.defaultTab} + > + {t("toolBar.library")} + + + {appState.openDialog?.name === "ttd" && } + {/* ------------------------------------------------------------------ */} + + {appState.isLoading && } + {appState.errorMessage && ( + setAppState({ errorMessage: null })}> + {appState.errorMessage} + + )} + {eyeDropperState && !device.editor.isMobile && ( + { + setEyeDropperState(null); + }} + onChange={(colorPickerType, color, selectedElements, { altKey }) => { + if ( + colorPickerType !== "elementBackground" && + colorPickerType !== "elementStroke" + ) { + return; + } + + if (selectedElements.length) { + for (const element of selectedElements) { + mutateElement( + element, + { + [altKey && eyeDropperState.swapPreviewOnAlt + ? colorPickerType === "elementBackground" + ? "strokeColor" + : "backgroundColor" + : colorPickerType === "elementBackground" + ? "backgroundColor" + : "strokeColor"]: color, + }, + false, + ); + ShapeCache.delete(element); + } + Scene.getScene(selectedElements[0])?.triggerUpdate(); + } else if (colorPickerType === "elementBackground") { + setAppState({ + currentItemBackgroundColor: color, + }); + } else { + setAppState({ currentItemStrokeColor: color }); + } + }} + onSelect={(color, event) => { + setEyeDropperState((state) => { + return state?.keepOpenOnAlt && event.altKey ? state : null; + }); + eyeDropperState?.onSelect?.(color, event); + }} + /> + )} + {appState.openDialog?.name === "help" && ( + { + setAppState({ openDialog: null }); + }} + /> + )} + + {appState.openDialog?.name === "elementLinkSelector" && ( + { + setAppState({ + openDialog: null, + }); + }} + elementsMap={app.scene.getNonDeletedElementsMap()} + appState={appState} + generateLinkForSelection={generateLinkForSelection} + /> + )} + + {renderImageExportDialog()} + {renderJSONExportDialog()} + {appState.pasteDialog.shown && ( + + setAppState({ + pasteDialog: { shown: false, data: null }, + }) + } + /> + )} + {device.editor.isMobile && ( + + )} + {!device.editor.isMobile && ( + <> +
+ {renderWelcomeScreen && } + {renderFixedSideContainer()} +
+ {appState.scrolledOutside && ( + + )} +
+ {renderSidebars()} + + )} + + ); + + return ( + + + + {layerUIJSX} + + + + ); +}; + +const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => { + const { + suggestedBindings, + startBoundElement, + cursorButton, + scrollX, + scrollY, + ...ret + } = appState; + return ret; +}; + +const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => { + // short-circuit early + if (prevProps.children !== nextProps.children) { + return false; + } + + const { canvas: _pC, appState: prevAppState, ...prev } = prevProps; + const { canvas: _nC, appState: nextAppState, ...next } = nextProps; + + return ( + isShallowEqual( + // asserting AppState because we're being passed the whole AppState + // but resolve to only the UI-relevant props + stripIrrelevantAppStateProps(prevAppState as AppState), + stripIrrelevantAppStateProps(nextAppState as AppState), + { + selectedElementIds: isShallowEqual, + selectedGroupIds: isShallowEqual, + }, + ) && isShallowEqual(prev, next) + ); +}; + +export default React.memo(LayerUI, areEqual); -- cgit v1.2.3