actionProperties.tsx 28 KB

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