actionProperties.tsx 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056
  1. import { AppState } from "../../src/types";
  2. import { ButtonIconSelect } from "../components/ButtonIconSelect";
  3. import { ColorPicker } from "../components/ColorPicker";
  4. import { IconPicker } from "../components/IconPicker";
  5. import {
  6. ArrowheadArrowIcon,
  7. ArrowheadBarIcon,
  8. ArrowheadDotIcon,
  9. ArrowheadTriangleIcon,
  10. ArrowheadNoneIcon,
  11. EdgeRoundIcon,
  12. EdgeSharpIcon,
  13. FillCrossHatchIcon,
  14. FillHachureIcon,
  15. FillSolidIcon,
  16. FontFamilyCodeIcon,
  17. FontFamilyHandDrawnIcon,
  18. FontFamilyNormalIcon,
  19. FontSizeExtraLargeIcon,
  20. FontSizeLargeIcon,
  21. FontSizeMediumIcon,
  22. FontSizeSmallIcon,
  23. SloppinessArchitectIcon,
  24. SloppinessArtistIcon,
  25. SloppinessCartoonistIcon,
  26. StrokeStyleDashedIcon,
  27. StrokeStyleDottedIcon,
  28. StrokeStyleSolidIcon,
  29. StrokeWidthIcon,
  30. TextAlignCenterIcon,
  31. TextAlignLeftIcon,
  32. TextAlignRightIcon,
  33. TextAlignTopIcon,
  34. TextAlignBottomIcon,
  35. TextAlignMiddleIcon,
  36. } from "../components/icons";
  37. import {
  38. DEFAULT_FONT_FAMILY,
  39. DEFAULT_FONT_SIZE,
  40. FONT_FAMILY,
  41. VERTICAL_ALIGN,
  42. } from "../constants";
  43. import {
  44. getNonDeletedElements,
  45. isTextElement,
  46. redrawTextBoundingBox,
  47. } from "../element";
  48. import { mutateElement, newElementWith } from "../element/mutateElement";
  49. import {
  50. getBoundTextElement,
  51. getContainerElement,
  52. } from "../element/textElement";
  53. import {
  54. isBoundToContainer,
  55. isLinearElement,
  56. isLinearElementType,
  57. } from "../element/typeChecks";
  58. import {
  59. Arrowhead,
  60. ExcalidrawElement,
  61. ExcalidrawLinearElement,
  62. ExcalidrawTextElement,
  63. FontFamilyValues,
  64. TextAlign,
  65. VerticalAlign,
  66. } from "../element/types";
  67. import { getLanguage, t } from "../i18n";
  68. import { KEYS } from "../keys";
  69. import { randomInteger } from "../random";
  70. import {
  71. canChangeSharpness,
  72. canHaveArrowheads,
  73. getCommonAttributeOfSelectedElements,
  74. getSelectedElements,
  75. getTargetElements,
  76. isSomeElementSelected,
  77. } from "../scene";
  78. import { hasStrokeColor } from "../scene/comparisons";
  79. import { arrayToMap } from "../utils";
  80. import { register } from "./register";
  81. const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
  82. const changeProperty = (
  83. elements: readonly ExcalidrawElement[],
  84. appState: AppState,
  85. callback: (element: ExcalidrawElement) => ExcalidrawElement,
  86. includeBoundText = false,
  87. ) => {
  88. const selectedElementIds = arrayToMap(
  89. getSelectedElements(elements, appState, includeBoundText),
  90. );
  91. return elements.map((element) => {
  92. if (
  93. selectedElementIds.get(element.id) ||
  94. element.id === appState.editingElement?.id
  95. ) {
  96. return callback(element);
  97. }
  98. return element;
  99. });
  100. };
  101. const getFormValue = function <T>(
  102. elements: readonly ExcalidrawElement[],
  103. appState: AppState,
  104. getAttribute: (element: ExcalidrawElement) => T,
  105. defaultValue?: T,
  106. ): T | null {
  107. const editingElement = appState.editingElement;
  108. const nonDeletedElements = getNonDeletedElements(elements);
  109. return (
  110. (editingElement && getAttribute(editingElement)) ??
  111. (isSomeElementSelected(nonDeletedElements, appState)
  112. ? getCommonAttributeOfSelectedElements(
  113. nonDeletedElements,
  114. appState,
  115. getAttribute,
  116. )
  117. : defaultValue) ??
  118. null
  119. );
  120. };
  121. const offsetElementAfterFontResize = (
  122. prevElement: ExcalidrawTextElement,
  123. nextElement: ExcalidrawTextElement,
  124. ) => {
  125. if (isBoundToContainer(nextElement)) {
  126. return nextElement;
  127. }
  128. return mutateElement(
  129. nextElement,
  130. {
  131. x:
  132. prevElement.textAlign === "left"
  133. ? prevElement.x
  134. : prevElement.x +
  135. (prevElement.width - nextElement.width) /
  136. (prevElement.textAlign === "center" ? 2 : 1),
  137. // centering vertically is non-standard, but for Excalidraw I think
  138. // it makes sense
  139. y: prevElement.y + (prevElement.height - nextElement.height) / 2,
  140. },
  141. false,
  142. );
  143. };
  144. const changeFontSize = (
  145. elements: readonly ExcalidrawElement[],
  146. appState: AppState,
  147. getNewFontSize: (element: ExcalidrawTextElement) => number,
  148. fallbackValue?: ExcalidrawTextElement["fontSize"],
  149. ) => {
  150. const newFontSizes = new Set<number>();
  151. return {
  152. elements: changeProperty(
  153. elements,
  154. appState,
  155. (oldElement) => {
  156. if (isTextElement(oldElement)) {
  157. const newFontSize = getNewFontSize(oldElement);
  158. newFontSizes.add(newFontSize);
  159. let newElement: ExcalidrawTextElement = newElementWith(oldElement, {
  160. fontSize: newFontSize,
  161. });
  162. redrawTextBoundingBox(newElement, getContainerElement(oldElement));
  163. newElement = offsetElementAfterFontResize(oldElement, newElement);
  164. return newElement;
  165. }
  166. return oldElement;
  167. },
  168. true,
  169. ),
  170. appState: {
  171. ...appState,
  172. // update state only if we've set all select text elements to
  173. // the same font size
  174. currentItemFontSize:
  175. newFontSizes.size === 1
  176. ? [...newFontSizes][0]
  177. : fallbackValue ?? appState.currentItemFontSize,
  178. },
  179. commitToHistory: true,
  180. };
  181. };
  182. // -----------------------------------------------------------------------------
  183. export const actionChangeStrokeColor = register({
  184. name: "changeStrokeColor",
  185. trackEvent: false,
  186. perform: (elements, appState, value) => {
  187. return {
  188. ...(value.currentItemStrokeColor && {
  189. elements: changeProperty(
  190. elements,
  191. appState,
  192. (el) => {
  193. return hasStrokeColor(el.type)
  194. ? newElementWith(el, {
  195. strokeColor: value.currentItemStrokeColor,
  196. })
  197. : el;
  198. },
  199. true,
  200. ),
  201. }),
  202. appState: {
  203. ...appState,
  204. ...value,
  205. },
  206. commitToHistory: !!value.currentItemStrokeColor,
  207. };
  208. },
  209. PanelComponent: ({ elements, appState, updateData }) => (
  210. <>
  211. <h3 aria-hidden="true">{t("labels.stroke")}</h3>
  212. <ColorPicker
  213. type="elementStroke"
  214. label={t("labels.stroke")}
  215. color={getFormValue(
  216. elements,
  217. appState,
  218. (element) => element.strokeColor,
  219. appState.currentItemStrokeColor,
  220. )}
  221. onChange={(color) => updateData({ currentItemStrokeColor: color })}
  222. isActive={appState.openPopup === "strokeColorPicker"}
  223. setActive={(active) =>
  224. updateData({ openPopup: active ? "strokeColorPicker" : null })
  225. }
  226. elements={elements}
  227. appState={appState}
  228. />
  229. </>
  230. ),
  231. });
  232. export const actionChangeBackgroundColor = register({
  233. name: "changeBackgroundColor",
  234. trackEvent: false,
  235. perform: (elements, appState, value) => {
  236. return {
  237. ...(value.currentItemBackgroundColor && {
  238. elements: changeProperty(elements, appState, (el) =>
  239. newElementWith(el, {
  240. backgroundColor: value.currentItemBackgroundColor,
  241. }),
  242. ),
  243. }),
  244. appState: {
  245. ...appState,
  246. ...value,
  247. },
  248. commitToHistory: !!value.currentItemBackgroundColor,
  249. };
  250. },
  251. PanelComponent: ({ elements, appState, updateData }) => (
  252. <>
  253. <h3 aria-hidden="true">{t("labels.background")}</h3>
  254. <ColorPicker
  255. type="elementBackground"
  256. label={t("labels.background")}
  257. color={getFormValue(
  258. elements,
  259. appState,
  260. (element) => element.backgroundColor,
  261. appState.currentItemBackgroundColor,
  262. )}
  263. onChange={(color) => updateData({ currentItemBackgroundColor: color })}
  264. isActive={appState.openPopup === "backgroundColorPicker"}
  265. setActive={(active) =>
  266. updateData({ openPopup: active ? "backgroundColorPicker" : null })
  267. }
  268. elements={elements}
  269. appState={appState}
  270. />
  271. </>
  272. ),
  273. });
  274. export const actionChangeFillStyle = register({
  275. name: "changeFillStyle",
  276. trackEvent: false,
  277. perform: (elements, appState, value) => {
  278. return {
  279. elements: changeProperty(elements, appState, (el) =>
  280. newElementWith(el, {
  281. fillStyle: value,
  282. }),
  283. ),
  284. appState: { ...appState, currentItemFillStyle: value },
  285. commitToHistory: true,
  286. };
  287. },
  288. PanelComponent: ({ elements, appState, updateData }) => (
  289. <fieldset>
  290. <legend>{t("labels.fill")}</legend>
  291. <ButtonIconSelect
  292. options={[
  293. {
  294. value: "hachure",
  295. text: t("labels.hachure"),
  296. icon: <FillHachureIcon theme={appState.theme} />,
  297. },
  298. {
  299. value: "cross-hatch",
  300. text: t("labels.crossHatch"),
  301. icon: <FillCrossHatchIcon theme={appState.theme} />,
  302. },
  303. {
  304. value: "solid",
  305. text: t("labels.solid"),
  306. icon: <FillSolidIcon theme={appState.theme} />,
  307. },
  308. ]}
  309. group="fill"
  310. value={getFormValue(
  311. elements,
  312. appState,
  313. (element) => element.fillStyle,
  314. appState.currentItemFillStyle,
  315. )}
  316. onChange={(value) => {
  317. updateData(value);
  318. }}
  319. />
  320. </fieldset>
  321. ),
  322. });
  323. export const actionChangeStrokeWidth = register({
  324. name: "changeStrokeWidth",
  325. trackEvent: false,
  326. perform: (elements, appState, value) => {
  327. return {
  328. elements: changeProperty(elements, appState, (el) =>
  329. newElementWith(el, {
  330. strokeWidth: value,
  331. }),
  332. ),
  333. appState: { ...appState, currentItemStrokeWidth: value },
  334. commitToHistory: true,
  335. };
  336. },
  337. PanelComponent: ({ elements, appState, updateData }) => (
  338. <fieldset>
  339. <legend>{t("labels.strokeWidth")}</legend>
  340. <ButtonIconSelect
  341. group="stroke-width"
  342. options={[
  343. {
  344. value: 1,
  345. text: t("labels.thin"),
  346. icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={2} />,
  347. },
  348. {
  349. value: 2,
  350. text: t("labels.bold"),
  351. icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={6} />,
  352. },
  353. {
  354. value: 4,
  355. text: t("labels.extraBold"),
  356. icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={10} />,
  357. },
  358. ]}
  359. value={getFormValue(
  360. elements,
  361. appState,
  362. (element) => element.strokeWidth,
  363. appState.currentItemStrokeWidth,
  364. )}
  365. onChange={(value) => updateData(value)}
  366. />
  367. </fieldset>
  368. ),
  369. });
  370. export const actionChangeSloppiness = register({
  371. name: "changeSloppiness",
  372. trackEvent: false,
  373. perform: (elements, appState, value) => {
  374. return {
  375. elements: changeProperty(elements, appState, (el) =>
  376. newElementWith(el, {
  377. seed: randomInteger(),
  378. roughness: value,
  379. }),
  380. ),
  381. appState: { ...appState, currentItemRoughness: value },
  382. commitToHistory: true,
  383. };
  384. },
  385. PanelComponent: ({ elements, appState, updateData }) => (
  386. <fieldset>
  387. <legend>{t("labels.sloppiness")}</legend>
  388. <ButtonIconSelect
  389. group="sloppiness"
  390. options={[
  391. {
  392. value: 0,
  393. text: t("labels.architect"),
  394. icon: <SloppinessArchitectIcon theme={appState.theme} />,
  395. },
  396. {
  397. value: 1,
  398. text: t("labels.artist"),
  399. icon: <SloppinessArtistIcon theme={appState.theme} />,
  400. },
  401. {
  402. value: 2,
  403. text: t("labels.cartoonist"),
  404. icon: <SloppinessCartoonistIcon theme={appState.theme} />,
  405. },
  406. ]}
  407. value={getFormValue(
  408. elements,
  409. appState,
  410. (element) => element.roughness,
  411. appState.currentItemRoughness,
  412. )}
  413. onChange={(value) => updateData(value)}
  414. />
  415. </fieldset>
  416. ),
  417. });
  418. export const actionChangeStrokeStyle = register({
  419. name: "changeStrokeStyle",
  420. trackEvent: false,
  421. perform: (elements, appState, value) => {
  422. return {
  423. elements: changeProperty(elements, appState, (el) =>
  424. newElementWith(el, {
  425. strokeStyle: value,
  426. }),
  427. ),
  428. appState: { ...appState, currentItemStrokeStyle: value },
  429. commitToHistory: true,
  430. };
  431. },
  432. PanelComponent: ({ elements, appState, updateData }) => (
  433. <fieldset>
  434. <legend>{t("labels.strokeStyle")}</legend>
  435. <ButtonIconSelect
  436. group="strokeStyle"
  437. options={[
  438. {
  439. value: "solid",
  440. text: t("labels.strokeStyle_solid"),
  441. icon: <StrokeStyleSolidIcon theme={appState.theme} />,
  442. },
  443. {
  444. value: "dashed",
  445. text: t("labels.strokeStyle_dashed"),
  446. icon: <StrokeStyleDashedIcon theme={appState.theme} />,
  447. },
  448. {
  449. value: "dotted",
  450. text: t("labels.strokeStyle_dotted"),
  451. icon: <StrokeStyleDottedIcon theme={appState.theme} />,
  452. },
  453. ]}
  454. value={getFormValue(
  455. elements,
  456. appState,
  457. (element) => element.strokeStyle,
  458. appState.currentItemStrokeStyle,
  459. )}
  460. onChange={(value) => updateData(value)}
  461. />
  462. </fieldset>
  463. ),
  464. });
  465. export const actionChangeOpacity = register({
  466. name: "changeOpacity",
  467. trackEvent: false,
  468. perform: (elements, appState, value) => {
  469. return {
  470. elements: changeProperty(
  471. elements,
  472. appState,
  473. (el) =>
  474. newElementWith(el, {
  475. opacity: value,
  476. }),
  477. true,
  478. ),
  479. appState: { ...appState, currentItemOpacity: value },
  480. commitToHistory: true,
  481. };
  482. },
  483. PanelComponent: ({ elements, appState, updateData }) => (
  484. <label className="control-label">
  485. {t("labels.opacity")}
  486. <input
  487. type="range"
  488. min="0"
  489. max="100"
  490. step="10"
  491. onChange={(event) => updateData(+event.target.value)}
  492. value={
  493. getFormValue(
  494. elements,
  495. appState,
  496. (element) => element.opacity,
  497. appState.currentItemOpacity,
  498. ) ?? undefined
  499. }
  500. />
  501. </label>
  502. ),
  503. });
  504. export const actionChangeFontSize = register({
  505. name: "changeFontSize",
  506. trackEvent: false,
  507. perform: (elements, appState, value) => {
  508. return changeFontSize(elements, appState, () => value, value);
  509. },
  510. PanelComponent: ({ elements, appState, updateData }) => (
  511. <fieldset>
  512. <legend>{t("labels.fontSize")}</legend>
  513. <ButtonIconSelect
  514. group="font-size"
  515. options={[
  516. {
  517. value: 16,
  518. text: t("labels.small"),
  519. icon: <FontSizeSmallIcon theme={appState.theme} />,
  520. testId: "fontSize-small",
  521. },
  522. {
  523. value: 20,
  524. text: t("labels.medium"),
  525. icon: <FontSizeMediumIcon theme={appState.theme} />,
  526. testId: "fontSize-medium",
  527. },
  528. {
  529. value: 28,
  530. text: t("labels.large"),
  531. icon: <FontSizeLargeIcon theme={appState.theme} />,
  532. testId: "fontSize-large",
  533. },
  534. {
  535. value: 36,
  536. text: t("labels.veryLarge"),
  537. icon: <FontSizeExtraLargeIcon theme={appState.theme} />,
  538. testId: "fontSize-veryLarge",
  539. },
  540. ]}
  541. value={getFormValue(
  542. elements,
  543. appState,
  544. (element) => {
  545. if (isTextElement(element)) {
  546. return element.fontSize;
  547. }
  548. const boundTextElement = getBoundTextElement(element);
  549. if (boundTextElement) {
  550. return boundTextElement.fontSize;
  551. }
  552. return null;
  553. },
  554. appState.currentItemFontSize || DEFAULT_FONT_SIZE,
  555. )}
  556. onChange={(value) => updateData(value)}
  557. />
  558. </fieldset>
  559. ),
  560. });
  561. export const actionDecreaseFontSize = register({
  562. name: "decreaseFontSize",
  563. trackEvent: false,
  564. perform: (elements, appState, value) => {
  565. return changeFontSize(elements, appState, (element) =>
  566. Math.round(
  567. // get previous value before relative increase (doesn't work fully
  568. // due to rounding and float precision issues)
  569. (1 / (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)) * element.fontSize,
  570. ),
  571. );
  572. },
  573. keyTest: (event) => {
  574. return (
  575. event[KEYS.CTRL_OR_CMD] &&
  576. event.shiftKey &&
  577. // KEYS.COMMA needed for MacOS
  578. (event.key === KEYS.CHEVRON_LEFT || event.key === KEYS.COMMA)
  579. );
  580. },
  581. });
  582. export const actionIncreaseFontSize = register({
  583. name: "increaseFontSize",
  584. trackEvent: false,
  585. perform: (elements, appState, value) => {
  586. return changeFontSize(elements, appState, (element) =>
  587. Math.round(element.fontSize * (1 + FONT_SIZE_RELATIVE_INCREASE_STEP)),
  588. );
  589. },
  590. keyTest: (event) => {
  591. return (
  592. event[KEYS.CTRL_OR_CMD] &&
  593. event.shiftKey &&
  594. // KEYS.PERIOD needed for MacOS
  595. (event.key === KEYS.CHEVRON_RIGHT || event.key === KEYS.PERIOD)
  596. );
  597. },
  598. });
  599. export const actionChangeFontFamily = register({
  600. name: "changeFontFamily",
  601. trackEvent: false,
  602. perform: (elements, appState, value) => {
  603. return {
  604. elements: changeProperty(
  605. elements,
  606. appState,
  607. (oldElement) => {
  608. if (isTextElement(oldElement)) {
  609. const newElement: ExcalidrawTextElement = newElementWith(
  610. oldElement,
  611. {
  612. fontFamily: value,
  613. },
  614. );
  615. redrawTextBoundingBox(newElement, getContainerElement(oldElement));
  616. return newElement;
  617. }
  618. return oldElement;
  619. },
  620. true,
  621. ),
  622. appState: {
  623. ...appState,
  624. currentItemFontFamily: value,
  625. },
  626. commitToHistory: true,
  627. };
  628. },
  629. PanelComponent: ({ elements, appState, updateData }) => {
  630. const options: {
  631. value: FontFamilyValues;
  632. text: string;
  633. icon: JSX.Element;
  634. }[] = [
  635. {
  636. value: FONT_FAMILY.Virgil,
  637. text: t("labels.handDrawn"),
  638. icon: <FontFamilyHandDrawnIcon theme={appState.theme} />,
  639. },
  640. {
  641. value: FONT_FAMILY.Helvetica,
  642. text: t("labels.normal"),
  643. icon: <FontFamilyNormalIcon theme={appState.theme} />,
  644. },
  645. {
  646. value: FONT_FAMILY.Cascadia,
  647. text: t("labels.code"),
  648. icon: <FontFamilyCodeIcon theme={appState.theme} />,
  649. },
  650. ];
  651. return (
  652. <fieldset>
  653. <legend>{t("labels.fontFamily")}</legend>
  654. <ButtonIconSelect<FontFamilyValues | false>
  655. group="font-family"
  656. options={options}
  657. value={getFormValue(
  658. elements,
  659. appState,
  660. (element) => {
  661. if (isTextElement(element)) {
  662. return element.fontFamily;
  663. }
  664. const boundTextElement = getBoundTextElement(element);
  665. if (boundTextElement) {
  666. return boundTextElement.fontFamily;
  667. }
  668. return null;
  669. },
  670. appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
  671. )}
  672. onChange={(value) => updateData(value)}
  673. />
  674. </fieldset>
  675. );
  676. },
  677. });
  678. export const actionChangeTextAlign = register({
  679. name: "changeTextAlign",
  680. trackEvent: false,
  681. perform: (elements, appState, value) => {
  682. return {
  683. elements: changeProperty(
  684. elements,
  685. appState,
  686. (oldElement) => {
  687. if (isTextElement(oldElement)) {
  688. const newElement: ExcalidrawTextElement = newElementWith(
  689. oldElement,
  690. { textAlign: value },
  691. );
  692. redrawTextBoundingBox(newElement, getContainerElement(oldElement));
  693. return newElement;
  694. }
  695. return oldElement;
  696. },
  697. true,
  698. ),
  699. appState: {
  700. ...appState,
  701. currentItemTextAlign: value,
  702. },
  703. commitToHistory: true,
  704. };
  705. },
  706. PanelComponent: ({ elements, appState, updateData }) => {
  707. return (
  708. <fieldset>
  709. <legend>{t("labels.textAlign")}</legend>
  710. <ButtonIconSelect<TextAlign | false>
  711. group="text-align"
  712. options={[
  713. {
  714. value: "left",
  715. text: t("labels.left"),
  716. icon: <TextAlignLeftIcon theme={appState.theme} />,
  717. },
  718. {
  719. value: "center",
  720. text: t("labels.center"),
  721. icon: <TextAlignCenterIcon theme={appState.theme} />,
  722. },
  723. {
  724. value: "right",
  725. text: t("labels.right"),
  726. icon: <TextAlignRightIcon theme={appState.theme} />,
  727. },
  728. ]}
  729. value={getFormValue(
  730. elements,
  731. appState,
  732. (element) => {
  733. if (isTextElement(element)) {
  734. return element.textAlign;
  735. }
  736. const boundTextElement = getBoundTextElement(element);
  737. if (boundTextElement) {
  738. return boundTextElement.textAlign;
  739. }
  740. return null;
  741. },
  742. appState.currentItemTextAlign,
  743. )}
  744. onChange={(value) => updateData(value)}
  745. />
  746. </fieldset>
  747. );
  748. },
  749. });
  750. export const actionChangeVerticalAlign = register({
  751. name: "changeVerticalAlign",
  752. trackEvent: { category: "element" },
  753. perform: (elements, appState, value) => {
  754. return {
  755. elements: changeProperty(
  756. elements,
  757. appState,
  758. (oldElement) => {
  759. if (isTextElement(oldElement)) {
  760. const newElement: ExcalidrawTextElement = newElementWith(
  761. oldElement,
  762. { verticalAlign: value },
  763. );
  764. redrawTextBoundingBox(newElement, getContainerElement(oldElement));
  765. return newElement;
  766. }
  767. return oldElement;
  768. },
  769. true,
  770. ),
  771. appState: {
  772. ...appState,
  773. },
  774. commitToHistory: true,
  775. };
  776. },
  777. PanelComponent: ({ elements, appState, updateData }) => {
  778. return (
  779. <fieldset>
  780. <ButtonIconSelect<VerticalAlign | false>
  781. group="text-align"
  782. options={[
  783. {
  784. value: VERTICAL_ALIGN.TOP,
  785. text: t("labels.alignTop"),
  786. icon: <TextAlignTopIcon theme={appState.theme} />,
  787. },
  788. {
  789. value: VERTICAL_ALIGN.MIDDLE,
  790. text: t("labels.centerVertically"),
  791. icon: <TextAlignMiddleIcon theme={appState.theme} />,
  792. },
  793. {
  794. value: VERTICAL_ALIGN.BOTTOM,
  795. text: t("labels.alignBottom"),
  796. icon: <TextAlignBottomIcon theme={appState.theme} />,
  797. },
  798. ]}
  799. value={getFormValue(elements, appState, (element) => {
  800. if (isTextElement(element) && element.containerId) {
  801. return element.verticalAlign;
  802. }
  803. const boundTextElement = getBoundTextElement(element);
  804. if (boundTextElement) {
  805. return boundTextElement.verticalAlign;
  806. }
  807. return null;
  808. })}
  809. onChange={(value) => updateData(value)}
  810. />
  811. </fieldset>
  812. );
  813. },
  814. });
  815. export const actionChangeSharpness = register({
  816. name: "changeSharpness",
  817. trackEvent: false,
  818. perform: (elements, appState, value) => {
  819. const targetElements = getTargetElements(
  820. getNonDeletedElements(elements),
  821. appState,
  822. );
  823. const shouldUpdateForNonLinearElements = targetElements.length
  824. ? targetElements.every((el) => !isLinearElement(el))
  825. : !isLinearElementType(appState.activeTool.type);
  826. const shouldUpdateForLinearElements = targetElements.length
  827. ? targetElements.every(isLinearElement)
  828. : isLinearElementType(appState.activeTool.type);
  829. return {
  830. elements: changeProperty(elements, appState, (el) =>
  831. newElementWith(el, {
  832. strokeSharpness: value,
  833. }),
  834. ),
  835. appState: {
  836. ...appState,
  837. currentItemStrokeSharpness: shouldUpdateForNonLinearElements
  838. ? value
  839. : appState.currentItemStrokeSharpness,
  840. currentItemLinearStrokeSharpness: shouldUpdateForLinearElements
  841. ? value
  842. : appState.currentItemLinearStrokeSharpness,
  843. },
  844. commitToHistory: true,
  845. };
  846. },
  847. PanelComponent: ({ elements, appState, updateData }) => (
  848. <fieldset>
  849. <legend>{t("labels.edges")}</legend>
  850. <ButtonIconSelect
  851. group="edges"
  852. options={[
  853. {
  854. value: "sharp",
  855. text: t("labels.sharp"),
  856. icon: <EdgeSharpIcon theme={appState.theme} />,
  857. },
  858. {
  859. value: "round",
  860. text: t("labels.round"),
  861. icon: <EdgeRoundIcon theme={appState.theme} />,
  862. },
  863. ]}
  864. value={getFormValue(
  865. elements,
  866. appState,
  867. (element) => element.strokeSharpness,
  868. (canChangeSharpness(appState.activeTool.type) &&
  869. (isLinearElementType(appState.activeTool.type)
  870. ? appState.currentItemLinearStrokeSharpness
  871. : appState.currentItemStrokeSharpness)) ||
  872. null,
  873. )}
  874. onChange={(value) => updateData(value)}
  875. />
  876. </fieldset>
  877. ),
  878. });
  879. export const actionChangeArrowhead = register({
  880. name: "changeArrowhead",
  881. trackEvent: false,
  882. perform: (
  883. elements,
  884. appState,
  885. value: { position: "start" | "end"; type: Arrowhead },
  886. ) => {
  887. return {
  888. elements: changeProperty(elements, appState, (el) => {
  889. if (isLinearElement(el)) {
  890. const { position, type } = value;
  891. if (position === "start") {
  892. const element: ExcalidrawLinearElement = newElementWith(el, {
  893. startArrowhead: type,
  894. });
  895. return element;
  896. } else if (position === "end") {
  897. const element: ExcalidrawLinearElement = newElementWith(el, {
  898. endArrowhead: type,
  899. });
  900. return element;
  901. }
  902. }
  903. return el;
  904. }),
  905. appState: {
  906. ...appState,
  907. [value.position === "start"
  908. ? "currentItemStartArrowhead"
  909. : "currentItemEndArrowhead"]: value.type,
  910. },
  911. commitToHistory: true,
  912. };
  913. },
  914. PanelComponent: ({ elements, appState, updateData }) => {
  915. const isRTL = getLanguage().rtl;
  916. return (
  917. <fieldset>
  918. <legend>{t("labels.arrowheads")}</legend>
  919. <div className="iconSelectList">
  920. <IconPicker
  921. label="arrowhead_start"
  922. options={[
  923. {
  924. value: null,
  925. text: t("labels.arrowhead_none"),
  926. icon: <ArrowheadNoneIcon theme={appState.theme} />,
  927. keyBinding: "q",
  928. },
  929. {
  930. value: "arrow",
  931. text: t("labels.arrowhead_arrow"),
  932. icon: (
  933. <ArrowheadArrowIcon theme={appState.theme} flip={!isRTL} />
  934. ),
  935. keyBinding: "w",
  936. },
  937. {
  938. value: "bar",
  939. text: t("labels.arrowhead_bar"),
  940. icon: <ArrowheadBarIcon theme={appState.theme} flip={!isRTL} />,
  941. keyBinding: "e",
  942. },
  943. {
  944. value: "dot",
  945. text: t("labels.arrowhead_dot"),
  946. icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />,
  947. keyBinding: "r",
  948. },
  949. {
  950. value: "triangle",
  951. text: t("labels.arrowhead_triangle"),
  952. icon: (
  953. <ArrowheadTriangleIcon theme={appState.theme} flip={!isRTL} />
  954. ),
  955. keyBinding: "t",
  956. },
  957. ]}
  958. value={getFormValue<Arrowhead | null>(
  959. elements,
  960. appState,
  961. (element) =>
  962. isLinearElement(element) && canHaveArrowheads(element.type)
  963. ? element.startArrowhead
  964. : appState.currentItemStartArrowhead,
  965. appState.currentItemStartArrowhead,
  966. )}
  967. onChange={(value) => updateData({ position: "start", type: value })}
  968. />
  969. <IconPicker
  970. label="arrowhead_end"
  971. group="arrowheads"
  972. options={[
  973. {
  974. value: null,
  975. text: t("labels.arrowhead_none"),
  976. keyBinding: "q",
  977. icon: <ArrowheadNoneIcon theme={appState.theme} />,
  978. },
  979. {
  980. value: "arrow",
  981. text: t("labels.arrowhead_arrow"),
  982. keyBinding: "w",
  983. icon: (
  984. <ArrowheadArrowIcon theme={appState.theme} flip={isRTL} />
  985. ),
  986. },
  987. {
  988. value: "bar",
  989. text: t("labels.arrowhead_bar"),
  990. keyBinding: "e",
  991. icon: <ArrowheadBarIcon theme={appState.theme} flip={isRTL} />,
  992. },
  993. {
  994. value: "dot",
  995. text: t("labels.arrowhead_dot"),
  996. keyBinding: "r",
  997. icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />,
  998. },
  999. {
  1000. value: "triangle",
  1001. text: t("labels.arrowhead_triangle"),
  1002. icon: (
  1003. <ArrowheadTriangleIcon theme={appState.theme} flip={isRTL} />
  1004. ),
  1005. keyBinding: "t",
  1006. },
  1007. ]}
  1008. value={getFormValue<Arrowhead | null>(
  1009. elements,
  1010. appState,
  1011. (element) =>
  1012. isLinearElement(element) && canHaveArrowheads(element.type)
  1013. ? element.endArrowhead
  1014. : appState.currentItemEndArrowhead,
  1015. appState.currentItemEndArrowhead,
  1016. )}
  1017. onChange={(value) => updateData({ position: "end", type: value })}
  1018. />
  1019. </div>
  1020. </fieldset>
  1021. );
  1022. },
  1023. });