diff options
Diffstat (limited to 'packages/excalidraw/components/Actions.tsx')
| -rw-r--r-- | packages/excalidraw/components/Actions.tsx | 478 |
1 files changed, 478 insertions, 0 deletions
diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx new file mode 100644 index 0000000..cd9120f --- /dev/null +++ b/packages/excalidraw/components/Actions.tsx @@ -0,0 +1,478 @@ +import { useState } from "react"; +import type { ActionManager } from "../actions/manager"; +import type { + ExcalidrawElement, + ExcalidrawElementType, + NonDeletedElementsMap, + NonDeletedSceneElementsMap, +} from "../element/types"; +import { t } from "../i18n"; +import { useDevice } from "./App"; +import { + canChangeRoundness, + canHaveArrowheads, + getTargetElements, + hasBackground, + hasStrokeStyle, + hasStrokeWidth, +} from "../scene"; +import { SHAPES } from "../shapes"; +import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types"; +import { capitalizeString, isTransparent } from "../utils"; +import Stack from "./Stack"; +import { ToolButton } from "./ToolButton"; +import { hasStrokeColor, toolIsArrow } from "../scene/comparisons"; +import { trackEvent } from "../analytics"; +import { + hasBoundTextElement, + isElbowArrow, + isImageElement, + isLinearElement, + isTextElement, +} from "../element/typeChecks"; +import clsx from "clsx"; +import { actionToggleZenMode } from "../actions"; +import { Tooltip } from "./Tooltip"; +import { + shouldAllowVerticalAlign, + suppportsHorizontalAlign, +} from "../element/textElement"; + +import "./Actions.scss"; +import DropdownMenu from "./dropdownMenu/DropdownMenu"; +import { + EmbedIcon, + extraToolsIcon, + frameToolIcon, + mermaidLogoIcon, + laserPointerToolIcon, + MagicIcon, +} from "./icons"; +import { KEYS } from "../keys"; +import { useTunnels } from "../context/tunnels"; +import { CLASSES } from "../constants"; +import { alignActionsPredicate } from "../actions/actionAlign"; + +export const canChangeStrokeColor = ( + appState: UIAppState, + targetElements: ExcalidrawElement[], +) => { + let commonSelectedType: ExcalidrawElementType | null = + targetElements[0]?.type || null; + + for (const element of targetElements) { + if (element.type !== commonSelectedType) { + commonSelectedType = null; + break; + } + } + + return ( + (hasStrokeColor(appState.activeTool.type) && + appState.activeTool.type !== "image" && + commonSelectedType !== "image" && + commonSelectedType !== "frame" && + commonSelectedType !== "magicframe") || + targetElements.some((element) => hasStrokeColor(element.type)) + ); +}; + +export const canChangeBackgroundColor = ( + appState: UIAppState, + targetElements: ExcalidrawElement[], +) => { + return ( + hasBackground(appState.activeTool.type) || + targetElements.some((element) => hasBackground(element.type)) + ); +}; + +export const SelectedShapeActions = ({ + appState, + elementsMap, + renderAction, + app, +}: { + appState: UIAppState; + elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap; + renderAction: ActionManager["renderAction"]; + app: AppClassProperties; +}) => { + const targetElements = getTargetElements(elementsMap, appState); + + let isSingleElementBoundContainer = false; + if ( + targetElements.length === 2 && + (hasBoundTextElement(targetElements[0]) || + hasBoundTextElement(targetElements[1])) + ) { + isSingleElementBoundContainer = true; + } + const isEditingTextOrNewElement = Boolean( + appState.editingTextElement || appState.newElement, + ); + const device = useDevice(); + const isRTL = document.documentElement.getAttribute("dir") === "rtl"; + + const showFillIcons = + (hasBackground(appState.activeTool.type) && + !isTransparent(appState.currentItemBackgroundColor)) || + targetElements.some( + (element) => + hasBackground(element.type) && !isTransparent(element.backgroundColor), + ); + + const showLinkIcon = + targetElements.length === 1 || isSingleElementBoundContainer; + + const showLineEditorAction = + !appState.editingLinearElement && + targetElements.length === 1 && + isLinearElement(targetElements[0]) && + !isElbowArrow(targetElements[0]); + + const showCropEditorAction = + !appState.croppingElementId && + targetElements.length === 1 && + isImageElement(targetElements[0]); + + const showAlignActions = + !isSingleElementBoundContainer && alignActionsPredicate(appState, app); + + return ( + <div className="panelColumn"> + <div> + {canChangeStrokeColor(appState, targetElements) && + renderAction("changeStrokeColor")} + </div> + {canChangeBackgroundColor(appState, targetElements) && ( + <div>{renderAction("changeBackgroundColor")}</div> + )} + {showFillIcons && renderAction("changeFillStyle")} + + {(hasStrokeWidth(appState.activeTool.type) || + targetElements.some((element) => hasStrokeWidth(element.type))) && + renderAction("changeStrokeWidth")} + + {(appState.activeTool.type === "freedraw" || + targetElements.some((element) => element.type === "freedraw")) && + renderAction("changeStrokeShape")} + + {(hasStrokeStyle(appState.activeTool.type) || + targetElements.some((element) => hasStrokeStyle(element.type))) && ( + <> + {renderAction("changeStrokeStyle")} + {renderAction("changeSloppiness")} + </> + )} + + {(canChangeRoundness(appState.activeTool.type) || + targetElements.some((element) => canChangeRoundness(element.type))) && ( + <>{renderAction("changeRoundness")}</> + )} + + {(toolIsArrow(appState.activeTool.type) || + targetElements.some((element) => toolIsArrow(element.type))) && ( + <>{renderAction("changeArrowType")}</> + )} + + {(appState.activeTool.type === "text" || + targetElements.some(isTextElement)) && ( + <> + {renderAction("changeFontFamily")} + {renderAction("changeFontSize")} + {(appState.activeTool.type === "text" || + suppportsHorizontalAlign(targetElements, elementsMap)) && + renderAction("changeTextAlign")} + </> + )} + + {shouldAllowVerticalAlign(targetElements, elementsMap) && + renderAction("changeVerticalAlign")} + {(canHaveArrowheads(appState.activeTool.type) || + targetElements.some((element) => canHaveArrowheads(element.type))) && ( + <>{renderAction("changeArrowhead")}</> + )} + + {renderAction("changeOpacity")} + + <fieldset> + <legend>{t("labels.layers")}</legend> + <div className="buttonList"> + {renderAction("sendToBack")} + {renderAction("sendBackward")} + {renderAction("bringForward")} + {renderAction("bringToFront")} + </div> + </fieldset> + + {showAlignActions && !isSingleElementBoundContainer && ( + <fieldset> + <legend>{t("labels.align")}</legend> + <div className="buttonList"> + { + // swap this order for RTL so the button positions always match their action + // (i.e. the leftmost button aligns left) + } + {isRTL ? ( + <> + {renderAction("alignRight")} + {renderAction("alignHorizontallyCentered")} + {renderAction("alignLeft")} + </> + ) : ( + <> + {renderAction("alignLeft")} + {renderAction("alignHorizontallyCentered")} + {renderAction("alignRight")} + </> + )} + {targetElements.length > 2 && + renderAction("distributeHorizontally")} + {/* breaks the row ˇˇ */} + <div style={{ flexBasis: "100%", height: 0 }} /> + <div + style={{ + display: "flex", + flexWrap: "wrap", + gap: ".5rem", + marginTop: "-0.5rem", + }} + > + {renderAction("alignTop")} + {renderAction("alignVerticallyCentered")} + {renderAction("alignBottom")} + {targetElements.length > 2 && + renderAction("distributeVertically")} + </div> + </div> + </fieldset> + )} + {!isEditingTextOrNewElement && targetElements.length > 0 && ( + <fieldset> + <legend>{t("labels.actions")}</legend> + <div className="buttonList"> + {!device.editor.isMobile && renderAction("duplicateSelection")} + {!device.editor.isMobile && renderAction("deleteSelectedElements")} + {renderAction("group")} + {renderAction("ungroup")} + {showLinkIcon && renderAction("hyperlink")} + {showCropEditorAction && renderAction("cropEditor")} + {showLineEditorAction && renderAction("toggleLinearEditor")} + </div> + </fieldset> + )} + </div> + ); +}; + +export const ShapesSwitcher = ({ + activeTool, + appState, + app, + UIOptions, +}: { + activeTool: UIAppState["activeTool"]; + appState: UIAppState; + app: AppClassProperties; + UIOptions: AppProps["UIOptions"]; +}) => { + const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false); + + const frameToolSelected = activeTool.type === "frame"; + const laserToolSelected = activeTool.type === "laser"; + const embeddableToolSelected = activeTool.type === "embeddable"; + + const { TTDDialogTriggerTunnel } = useTunnels(); + + return ( + <> + {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { + if ( + UIOptions.tools?.[ + value as Extract<typeof value, keyof AppProps["UIOptions"]["tools"]> + ] === false + ) { + return null; + } + + const label = t(`toolBar.${value}`); + const letter = + key && capitalizeString(typeof key === "string" ? key : key[0]); + const shortcut = letter + ? `${letter} ${t("helpDialog.or")} ${numericKey}` + : `${numericKey}`; + return ( + <ToolButton + className={clsx("Shape", { fillable })} + key={value} + type="radio" + icon={icon} + checked={activeTool.type === value} + name="editor-current-shape" + title={`${capitalizeString(label)} — ${shortcut}`} + keyBindingLabel={numericKey || letter} + aria-label={capitalizeString(label)} + aria-keyshortcuts={shortcut} + data-testid={`toolbar-${value}`} + onPointerDown={({ pointerType }) => { + if (!appState.penDetected && pointerType === "pen") { + app.togglePenMode(true); + } + }} + onChange={({ pointerType }) => { + if (appState.activeTool.type !== value) { + trackEvent("toolbar", value, "ui"); + } + if (value === "image") { + app.setActiveTool({ + type: value, + insertOnCanvasDirectly: pointerType !== "mouse", + }); + } else { + app.setActiveTool({ type: value }); + } + }} + /> + ); + })} + <div className="App-toolbar__divider" /> + + <DropdownMenu open={isExtraToolsMenuOpen}> + <DropdownMenu.Trigger + className={clsx("App-toolbar__extra-tools-trigger", { + "App-toolbar__extra-tools-trigger--selected": + frameToolSelected || + embeddableToolSelected || + // in collab we're already highlighting the laser button + // outside toolbar, so let's not highlight extra-tools button + // on top of it + (laserToolSelected && !app.props.isCollaborating), + })} + onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)} + title={t("toolBar.extraTools")} + > + {extraToolsIcon} + </DropdownMenu.Trigger> + <DropdownMenu.Content + onClickOutside={() => setIsExtraToolsMenuOpen(false)} + onSelect={() => setIsExtraToolsMenuOpen(false)} + className="App-toolbar__extra-tools-dropdown" + > + <DropdownMenu.Item + onSelect={() => app.setActiveTool({ type: "frame" })} + icon={frameToolIcon} + shortcut={KEYS.F.toLocaleUpperCase()} + data-testid="toolbar-frame" + selected={frameToolSelected} + > + {t("toolBar.frame")} + </DropdownMenu.Item> + <DropdownMenu.Item + onSelect={() => app.setActiveTool({ type: "embeddable" })} + icon={EmbedIcon} + data-testid="toolbar-embeddable" + selected={embeddableToolSelected} + > + {t("toolBar.embeddable")} + </DropdownMenu.Item> + <DropdownMenu.Item + onSelect={() => app.setActiveTool({ type: "laser" })} + icon={laserPointerToolIcon} + data-testid="toolbar-laser" + selected={laserToolSelected} + shortcut={KEYS.K.toLocaleUpperCase()} + > + {t("toolBar.laser")} + </DropdownMenu.Item> + <div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}> + Generate + </div> + {app.props.aiEnabled !== false && <TTDDialogTriggerTunnel.Out />} + <DropdownMenu.Item + onSelect={() => app.setOpenDialog({ name: "ttd", tab: "mermaid" })} + icon={mermaidLogoIcon} + data-testid="toolbar-embeddable" + > + {t("toolBar.mermaidToExcalidraw")} + </DropdownMenu.Item> + {app.props.aiEnabled !== false && app.plugins.diagramToCode && ( + <> + <DropdownMenu.Item + onSelect={() => app.onMagicframeToolSelect()} + icon={MagicIcon} + data-testid="toolbar-magicframe" + > + {t("toolBar.magicframe")} + <DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge> + </DropdownMenu.Item> + </> + )} + </DropdownMenu.Content> + </DropdownMenu> + </> + ); +}; + +export const ZoomActions = ({ + renderAction, + zoom, +}: { + renderAction: ActionManager["renderAction"]; + zoom: Zoom; +}) => ( + <Stack.Col gap={1} className={CLASSES.ZOOM_ACTIONS}> + <Stack.Row align="center"> + {renderAction("zoomOut")} + {renderAction("resetZoom")} + {renderAction("zoomIn")} + </Stack.Row> + </Stack.Col> +); + +export const UndoRedoActions = ({ + renderAction, + className, +}: { + renderAction: ActionManager["renderAction"]; + className?: string; +}) => ( + <div className={`undo-redo-buttons ${className}`}> + <div className="undo-button-container"> + <Tooltip label={t("buttons.undo")}>{renderAction("undo")}</Tooltip> + </div> + <div className="redo-button-container"> + <Tooltip label={t("buttons.redo")}> {renderAction("redo")}</Tooltip> + </div> + </div> +); + +export const ExitZenModeAction = ({ + actionManager, + showExitZenModeBtn, +}: { + actionManager: ActionManager; + showExitZenModeBtn: boolean; +}) => ( + <button + type="button" + className={clsx("disable-zen-mode", { + "disable-zen-mode--visible": showExitZenModeBtn, + })} + onClick={() => actionManager.executeAction(actionToggleZenMode)} + > + {t("buttons.exitZenMode")} + </button> +); + +export const FinalizeAction = ({ + renderAction, + className, +}: { + renderAction: ActionManager["renderAction"]; + className?: string; +}) => ( + <div className={`finalize-button ${className}`}> + {renderAction("finalize", { size: "small" })} + </div> +); |
