Actions.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. import React, { useState } from "react";
  2. import { ActionManager } from "../actions/manager";
  3. import { getNonDeletedElements } from "../element";
  4. import { ExcalidrawElement, ExcalidrawElementType } from "../element/types";
  5. import { t } from "../i18n";
  6. import { useDevice } from "./App";
  7. import {
  8. canChangeRoundness,
  9. canHaveArrowheads,
  10. getTargetElements,
  11. hasBackground,
  12. hasStrokeStyle,
  13. hasStrokeWidth,
  14. } from "../scene";
  15. import { SHAPES } from "../shapes";
  16. import { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
  17. import { capitalizeString, isTransparent } from "../utils";
  18. import Stack from "./Stack";
  19. import { ToolButton } from "./ToolButton";
  20. import { hasStrokeColor } from "../scene/comparisons";
  21. import { trackEvent } from "../analytics";
  22. import { hasBoundTextElement, isTextElement } from "../element/typeChecks";
  23. import clsx from "clsx";
  24. import { actionToggleZenMode } from "../actions";
  25. import { Tooltip } from "./Tooltip";
  26. import {
  27. shouldAllowVerticalAlign,
  28. suppportsHorizontalAlign,
  29. } from "../element/textElement";
  30. import "./Actions.scss";
  31. import DropdownMenu from "./dropdownMenu/DropdownMenu";
  32. import {
  33. EmbedIcon,
  34. extraToolsIcon,
  35. frameToolIcon,
  36. mermaidLogoIcon,
  37. laserPointerToolIcon,
  38. OpenAIIcon,
  39. MagicIcon,
  40. } from "./icons";
  41. import { KEYS } from "../keys";
  42. import { useTunnels } from "../context/tunnels";
  43. export const SelectedShapeActions = ({
  44. appState,
  45. elements,
  46. renderAction,
  47. }: {
  48. appState: UIAppState;
  49. elements: readonly ExcalidrawElement[];
  50. renderAction: ActionManager["renderAction"];
  51. }) => {
  52. const targetElements = getTargetElements(
  53. getNonDeletedElements(elements),
  54. appState,
  55. );
  56. let isSingleElementBoundContainer = false;
  57. if (
  58. targetElements.length === 2 &&
  59. (hasBoundTextElement(targetElements[0]) ||
  60. hasBoundTextElement(targetElements[1]))
  61. ) {
  62. isSingleElementBoundContainer = true;
  63. }
  64. const isEditing = Boolean(appState.editingElement);
  65. const device = useDevice();
  66. const isRTL = document.documentElement.getAttribute("dir") === "rtl";
  67. const showFillIcons =
  68. (hasBackground(appState.activeTool.type) &&
  69. !isTransparent(appState.currentItemBackgroundColor)) ||
  70. targetElements.some(
  71. (element) =>
  72. hasBackground(element.type) && !isTransparent(element.backgroundColor),
  73. );
  74. const showChangeBackgroundIcons =
  75. hasBackground(appState.activeTool.type) ||
  76. targetElements.some((element) => hasBackground(element.type));
  77. const showLinkIcon =
  78. targetElements.length === 1 || isSingleElementBoundContainer;
  79. let commonSelectedType: ExcalidrawElementType | null =
  80. targetElements[0]?.type || null;
  81. for (const element of targetElements) {
  82. if (element.type !== commonSelectedType) {
  83. commonSelectedType = null;
  84. break;
  85. }
  86. }
  87. return (
  88. <div className="panelColumn">
  89. <div>
  90. {((hasStrokeColor(appState.activeTool.type) &&
  91. appState.activeTool.type !== "image" &&
  92. commonSelectedType !== "image" &&
  93. commonSelectedType !== "frame" &&
  94. commonSelectedType !== "magicframe") ||
  95. targetElements.some((element) => hasStrokeColor(element.type))) &&
  96. renderAction("changeStrokeColor")}
  97. </div>
  98. {showChangeBackgroundIcons && (
  99. <div>{renderAction("changeBackgroundColor")}</div>
  100. )}
  101. {showFillIcons && renderAction("changeFillStyle")}
  102. {(hasStrokeWidth(appState.activeTool.type) ||
  103. targetElements.some((element) => hasStrokeWidth(element.type))) &&
  104. renderAction("changeStrokeWidth")}
  105. {(appState.activeTool.type === "freedraw" ||
  106. targetElements.some((element) => element.type === "freedraw")) &&
  107. renderAction("changeStrokeShape")}
  108. {(hasStrokeStyle(appState.activeTool.type) ||
  109. targetElements.some((element) => hasStrokeStyle(element.type))) && (
  110. <>
  111. {renderAction("changeStrokeStyle")}
  112. {renderAction("changeSloppiness")}
  113. </>
  114. )}
  115. {(canChangeRoundness(appState.activeTool.type) ||
  116. targetElements.some((element) => canChangeRoundness(element.type))) && (
  117. <>{renderAction("changeRoundness")}</>
  118. )}
  119. {(appState.activeTool.type === "text" ||
  120. targetElements.some(isTextElement)) && (
  121. <>
  122. {renderAction("changeFontSize")}
  123. {renderAction("changeFontFamily")}
  124. {(appState.activeTool.type === "text" ||
  125. suppportsHorizontalAlign(targetElements)) &&
  126. renderAction("changeTextAlign")}
  127. </>
  128. )}
  129. {shouldAllowVerticalAlign(targetElements) &&
  130. renderAction("changeVerticalAlign")}
  131. {(canHaveArrowheads(appState.activeTool.type) ||
  132. targetElements.some((element) => canHaveArrowheads(element.type))) && (
  133. <>{renderAction("changeArrowhead")}</>
  134. )}
  135. {renderAction("changeOpacity")}
  136. <fieldset>
  137. <legend>{t("labels.layers")}</legend>
  138. <div className="buttonList">
  139. {renderAction("sendToBack")}
  140. {renderAction("sendBackward")}
  141. {renderAction("bringToFront")}
  142. {renderAction("bringForward")}
  143. </div>
  144. </fieldset>
  145. {targetElements.length > 1 && !isSingleElementBoundContainer && (
  146. <fieldset>
  147. <legend>{t("labels.align")}</legend>
  148. <div className="buttonList">
  149. {
  150. // swap this order for RTL so the button positions always match their action
  151. // (i.e. the leftmost button aligns left)
  152. }
  153. {isRTL ? (
  154. <>
  155. {renderAction("alignRight")}
  156. {renderAction("alignHorizontallyCentered")}
  157. {renderAction("alignLeft")}
  158. </>
  159. ) : (
  160. <>
  161. {renderAction("alignLeft")}
  162. {renderAction("alignHorizontallyCentered")}
  163. {renderAction("alignRight")}
  164. </>
  165. )}
  166. {targetElements.length > 2 &&
  167. renderAction("distributeHorizontally")}
  168. {/* breaks the row ˇˇ */}
  169. <div style={{ flexBasis: "100%", height: 0 }} />
  170. <div
  171. style={{
  172. display: "flex",
  173. flexWrap: "wrap",
  174. gap: ".5rem",
  175. marginTop: "-0.5rem",
  176. }}
  177. >
  178. {renderAction("alignTop")}
  179. {renderAction("alignVerticallyCentered")}
  180. {renderAction("alignBottom")}
  181. {targetElements.length > 2 &&
  182. renderAction("distributeVertically")}
  183. </div>
  184. </div>
  185. </fieldset>
  186. )}
  187. {!isEditing && targetElements.length > 0 && (
  188. <fieldset>
  189. <legend>{t("labels.actions")}</legend>
  190. <div className="buttonList">
  191. {!device.editor.isMobile && renderAction("duplicateSelection")}
  192. {!device.editor.isMobile && renderAction("deleteSelectedElements")}
  193. {renderAction("group")}
  194. {renderAction("ungroup")}
  195. {showLinkIcon && renderAction("hyperlink")}
  196. </div>
  197. </fieldset>
  198. )}
  199. </div>
  200. );
  201. };
  202. export const ShapesSwitcher = ({
  203. activeTool,
  204. appState,
  205. app,
  206. UIOptions,
  207. }: {
  208. activeTool: UIAppState["activeTool"];
  209. appState: UIAppState;
  210. app: AppClassProperties;
  211. UIOptions: AppProps["UIOptions"];
  212. }) => {
  213. const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
  214. const frameToolSelected = activeTool.type === "frame";
  215. const laserToolSelected = activeTool.type === "laser";
  216. const embeddableToolSelected = activeTool.type === "embeddable";
  217. const { TTDDialogTriggerTunnel } = useTunnels();
  218. return (
  219. <>
  220. {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
  221. if (
  222. UIOptions.tools?.[
  223. value as Extract<typeof value, keyof AppProps["UIOptions"]["tools"]>
  224. ] === false
  225. ) {
  226. return null;
  227. }
  228. const label = t(`toolBar.${value}`);
  229. const letter =
  230. key && capitalizeString(typeof key === "string" ? key : key[0]);
  231. const shortcut = letter
  232. ? `${letter} ${t("helpDialog.or")} ${numericKey}`
  233. : `${numericKey}`;
  234. return (
  235. <ToolButton
  236. className={clsx("Shape", { fillable })}
  237. key={value}
  238. type="radio"
  239. icon={icon}
  240. checked={activeTool.type === value}
  241. name="editor-current-shape"
  242. title={`${capitalizeString(label)} — ${shortcut}`}
  243. keyBindingLabel={numericKey || letter}
  244. aria-label={capitalizeString(label)}
  245. aria-keyshortcuts={shortcut}
  246. data-testid={`toolbar-${value}`}
  247. onPointerDown={({ pointerType }) => {
  248. if (!appState.penDetected && pointerType === "pen") {
  249. app.togglePenMode(true);
  250. }
  251. }}
  252. onChange={({ pointerType }) => {
  253. if (appState.activeTool.type !== value) {
  254. trackEvent("toolbar", value, "ui");
  255. }
  256. if (value === "image") {
  257. app.setActiveTool({
  258. type: value,
  259. insertOnCanvasDirectly: pointerType !== "mouse",
  260. });
  261. } else {
  262. app.setActiveTool({ type: value });
  263. }
  264. }}
  265. />
  266. );
  267. })}
  268. <div className="App-toolbar__divider" />
  269. <DropdownMenu open={isExtraToolsMenuOpen}>
  270. <DropdownMenu.Trigger
  271. className={clsx("App-toolbar__extra-tools-trigger", {
  272. "App-toolbar__extra-tools-trigger--selected":
  273. frameToolSelected ||
  274. embeddableToolSelected ||
  275. // in collab we're already highlighting the laser button
  276. // outside toolbar, so let's not highlight extra-tools button
  277. // on top of it
  278. (laserToolSelected && !app.props.isCollaborating),
  279. })}
  280. onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
  281. title={t("toolBar.extraTools")}
  282. >
  283. {extraToolsIcon}
  284. </DropdownMenu.Trigger>
  285. <DropdownMenu.Content
  286. onClickOutside={() => setIsExtraToolsMenuOpen(false)}
  287. onSelect={() => setIsExtraToolsMenuOpen(false)}
  288. className="App-toolbar__extra-tools-dropdown"
  289. >
  290. <DropdownMenu.Item
  291. onSelect={() => app.setActiveTool({ type: "frame" })}
  292. icon={frameToolIcon}
  293. shortcut={KEYS.F.toLocaleUpperCase()}
  294. data-testid="toolbar-frame"
  295. selected={frameToolSelected}
  296. >
  297. {t("toolBar.frame")}
  298. </DropdownMenu.Item>
  299. <DropdownMenu.Item
  300. onSelect={() => app.setActiveTool({ type: "embeddable" })}
  301. icon={EmbedIcon}
  302. data-testid="toolbar-embeddable"
  303. selected={embeddableToolSelected}
  304. >
  305. {t("toolBar.embeddable")}
  306. </DropdownMenu.Item>
  307. <DropdownMenu.Item
  308. onSelect={() => app.setActiveTool({ type: "laser" })}
  309. icon={laserPointerToolIcon}
  310. data-testid="toolbar-laser"
  311. selected={laserToolSelected}
  312. shortcut={KEYS.K.toLocaleUpperCase()}
  313. >
  314. {t("toolBar.laser")}
  315. </DropdownMenu.Item>
  316. <div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
  317. Generate
  318. </div>
  319. {app.props.aiEnabled !== false && <TTDDialogTriggerTunnel.Out />}
  320. <DropdownMenu.Item
  321. onSelect={() => app.setOpenDialog({ name: "ttd", tab: "mermaid" })}
  322. icon={mermaidLogoIcon}
  323. data-testid="toolbar-embeddable"
  324. >
  325. {t("toolBar.mermaidToExcalidraw")}
  326. </DropdownMenu.Item>
  327. {app.props.aiEnabled !== false && (
  328. <>
  329. <DropdownMenu.Item
  330. onSelect={() => app.onMagicframeToolSelect()}
  331. icon={MagicIcon}
  332. data-testid="toolbar-magicframe"
  333. >
  334. {t("toolBar.magicframe")}
  335. <DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
  336. </DropdownMenu.Item>
  337. <DropdownMenu.Item
  338. onSelect={() => {
  339. trackEvent("ai", "open-settings", "d2c");
  340. app.setOpenDialog({
  341. name: "settings",
  342. source: "settings",
  343. tab: "diagram-to-code",
  344. });
  345. }}
  346. icon={OpenAIIcon}
  347. data-testid="toolbar-magicSettings"
  348. >
  349. {t("toolBar.magicSettings")}
  350. </DropdownMenu.Item>
  351. </>
  352. )}
  353. </DropdownMenu.Content>
  354. </DropdownMenu>
  355. </>
  356. );
  357. };
  358. export const ZoomActions = ({
  359. renderAction,
  360. zoom,
  361. }: {
  362. renderAction: ActionManager["renderAction"];
  363. zoom: Zoom;
  364. }) => (
  365. <Stack.Col gap={1} className="zoom-actions">
  366. <Stack.Row align="center">
  367. {renderAction("zoomOut")}
  368. {renderAction("resetZoom")}
  369. {renderAction("zoomIn")}
  370. </Stack.Row>
  371. </Stack.Col>
  372. );
  373. export const UndoRedoActions = ({
  374. renderAction,
  375. className,
  376. }: {
  377. renderAction: ActionManager["renderAction"];
  378. className?: string;
  379. }) => (
  380. <div className={`undo-redo-buttons ${className}`}>
  381. <div className="undo-button-container">
  382. <Tooltip label={t("buttons.undo")}>{renderAction("undo")}</Tooltip>
  383. </div>
  384. <div className="redo-button-container">
  385. <Tooltip label={t("buttons.redo")}> {renderAction("redo")}</Tooltip>
  386. </div>
  387. </div>
  388. );
  389. export const ExitZenModeAction = ({
  390. actionManager,
  391. showExitZenModeBtn,
  392. }: {
  393. actionManager: ActionManager;
  394. showExitZenModeBtn: boolean;
  395. }) => (
  396. <button
  397. className={clsx("disable-zen-mode", {
  398. "disable-zen-mode--visible": showExitZenModeBtn,
  399. })}
  400. onClick={() => actionManager.executeAction(actionToggleZenMode)}
  401. >
  402. {t("buttons.exitZenMode")}
  403. </button>
  404. );
  405. export const FinalizeAction = ({
  406. renderAction,
  407. className,
  408. }: {
  409. renderAction: ActionManager["renderAction"];
  410. className?: string;
  411. }) => (
  412. <div className={`finalize-button ${className}`}>
  413. {renderAction("finalize", { size: "small" })}
  414. </div>
  415. );