actionProperties.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. import React from "react";
  2. import {
  3. ExcalidrawElement,
  4. ExcalidrawTextElement,
  5. TextAlign,
  6. FontFamily,
  7. } from "../element/types";
  8. import {
  9. getCommonAttributeOfSelectedElements,
  10. isSomeElementSelected,
  11. } from "../scene";
  12. import { ButtonSelect } from "../components/ButtonSelect";
  13. import {
  14. isTextElement,
  15. redrawTextBoundingBox,
  16. getNonDeletedElements,
  17. } from "../element";
  18. import { ColorPicker } from "../components/ColorPicker";
  19. import { AppState } from "../../src/types";
  20. import { t } from "../i18n";
  21. import { register } from "./register";
  22. import { newElementWith } from "../element/mutateElement";
  23. import { DEFAULT_FONT_SIZE, DEFAULT_FONT_FAMILY } from "../appState";
  24. const changeProperty = (
  25. elements: readonly ExcalidrawElement[],
  26. appState: AppState,
  27. callback: (element: ExcalidrawElement) => ExcalidrawElement,
  28. ) => {
  29. return elements.map((element) => {
  30. if (
  31. appState.selectedElementIds[element.id] ||
  32. element.id === appState.editingElement?.id
  33. ) {
  34. return callback(element);
  35. }
  36. return element;
  37. });
  38. };
  39. const getFormValue = function <T>(
  40. elements: readonly ExcalidrawElement[],
  41. appState: AppState,
  42. getAttribute: (element: ExcalidrawElement) => T,
  43. defaultValue?: T,
  44. ): T | null {
  45. const editingElement = appState.editingElement;
  46. const nonDeletedElements = getNonDeletedElements(elements);
  47. return (
  48. (editingElement && getAttribute(editingElement)) ??
  49. (isSomeElementSelected(nonDeletedElements, appState)
  50. ? getCommonAttributeOfSelectedElements(
  51. nonDeletedElements,
  52. appState,
  53. getAttribute,
  54. )
  55. : defaultValue) ??
  56. null
  57. );
  58. };
  59. export const actionChangeStrokeColor = register({
  60. name: "changeStrokeColor",
  61. perform: (elements, appState, value) => {
  62. return {
  63. elements: changeProperty(elements, appState, (el) =>
  64. newElementWith(el, {
  65. strokeColor: value,
  66. }),
  67. ),
  68. appState: { ...appState, currentItemStrokeColor: value },
  69. commitToHistory: true,
  70. };
  71. },
  72. PanelComponent: ({ elements, appState, updateData }) => (
  73. <>
  74. <h3 aria-hidden="true">{t("labels.stroke")}</h3>
  75. <ColorPicker
  76. type="elementStroke"
  77. label={t("labels.stroke")}
  78. color={getFormValue(
  79. elements,
  80. appState,
  81. (element) => element.strokeColor,
  82. appState.currentItemStrokeColor,
  83. )}
  84. onChange={updateData}
  85. />
  86. </>
  87. ),
  88. });
  89. export const actionChangeBackgroundColor = register({
  90. name: "changeBackgroundColor",
  91. perform: (elements, appState, value) => {
  92. return {
  93. elements: changeProperty(elements, appState, (el) =>
  94. newElementWith(el, {
  95. backgroundColor: value,
  96. }),
  97. ),
  98. appState: { ...appState, currentItemBackgroundColor: value },
  99. commitToHistory: true,
  100. };
  101. },
  102. PanelComponent: ({ elements, appState, updateData }) => (
  103. <>
  104. <h3 aria-hidden="true">{t("labels.background")}</h3>
  105. <ColorPicker
  106. type="elementBackground"
  107. label={t("labels.background")}
  108. color={getFormValue(
  109. elements,
  110. appState,
  111. (element) => element.backgroundColor,
  112. appState.currentItemBackgroundColor,
  113. )}
  114. onChange={updateData}
  115. />
  116. </>
  117. ),
  118. });
  119. export const actionChangeFillStyle = register({
  120. name: "changeFillStyle",
  121. perform: (elements, appState, value) => {
  122. return {
  123. elements: changeProperty(elements, appState, (el) =>
  124. newElementWith(el, {
  125. fillStyle: value,
  126. }),
  127. ),
  128. appState: { ...appState, currentItemFillStyle: value },
  129. commitToHistory: true,
  130. };
  131. },
  132. PanelComponent: ({ elements, appState, updateData }) => (
  133. <fieldset>
  134. <legend>{t("labels.fill")}</legend>
  135. <ButtonSelect
  136. options={[
  137. { value: "hachure", text: t("labels.hachure") },
  138. { value: "cross-hatch", text: t("labels.crossHatch") },
  139. { value: "solid", text: t("labels.solid") },
  140. ]}
  141. group="fill"
  142. value={getFormValue(
  143. elements,
  144. appState,
  145. (element) => element.fillStyle,
  146. appState.currentItemFillStyle,
  147. )}
  148. onChange={(value) => {
  149. updateData(value);
  150. }}
  151. />
  152. </fieldset>
  153. ),
  154. });
  155. export const actionChangeStrokeWidth = register({
  156. name: "changeStrokeWidth",
  157. perform: (elements, appState, value) => {
  158. return {
  159. elements: changeProperty(elements, appState, (el) =>
  160. newElementWith(el, {
  161. strokeWidth: value,
  162. }),
  163. ),
  164. appState: { ...appState, currentItemStrokeWidth: value },
  165. commitToHistory: true,
  166. };
  167. },
  168. PanelComponent: ({ elements, appState, updateData }) => (
  169. <fieldset>
  170. <legend>{t("labels.strokeWidth")}</legend>
  171. <ButtonSelect
  172. group="stroke-width"
  173. options={[
  174. { value: 1, text: t("labels.thin") },
  175. { value: 2, text: t("labels.bold") },
  176. { value: 4, text: t("labels.extraBold") },
  177. ]}
  178. value={getFormValue(
  179. elements,
  180. appState,
  181. (element) => element.strokeWidth,
  182. appState.currentItemStrokeWidth,
  183. )}
  184. onChange={(value) => updateData(value)}
  185. />
  186. </fieldset>
  187. ),
  188. });
  189. export const actionChangeSloppiness = register({
  190. name: "changeSloppiness",
  191. perform: (elements, appState, value) => {
  192. return {
  193. elements: changeProperty(elements, appState, (el) =>
  194. newElementWith(el, {
  195. roughness: value,
  196. }),
  197. ),
  198. appState: { ...appState, currentItemRoughness: value },
  199. commitToHistory: true,
  200. };
  201. },
  202. PanelComponent: ({ elements, appState, updateData }) => (
  203. <fieldset>
  204. <legend>{t("labels.sloppiness")}</legend>
  205. <ButtonSelect
  206. group="sloppiness"
  207. options={[
  208. { value: 0, text: t("labels.architect") },
  209. { value: 1, text: t("labels.artist") },
  210. { value: 2, text: t("labels.cartoonist") },
  211. ]}
  212. value={getFormValue(
  213. elements,
  214. appState,
  215. (element) => element.roughness,
  216. appState.currentItemRoughness,
  217. )}
  218. onChange={(value) => updateData(value)}
  219. />
  220. </fieldset>
  221. ),
  222. });
  223. export const actionChangeStrokeStyle = register({
  224. name: "changeStrokeStyle",
  225. perform: (elements, appState, value) => {
  226. return {
  227. elements: changeProperty(elements, appState, (el) =>
  228. newElementWith(el, {
  229. strokeStyle: value,
  230. }),
  231. ),
  232. appState: { ...appState, currentItemStrokeStyle: value },
  233. commitToHistory: true,
  234. };
  235. },
  236. PanelComponent: ({ elements, appState, updateData }) => (
  237. <fieldset>
  238. <legend>{t("labels.strokeStyle")}</legend>
  239. <ButtonSelect
  240. group="strokeStyle"
  241. options={[
  242. { value: "solid", text: t("labels.strokeStyle_solid") },
  243. { value: "dashed", text: t("labels.strokeStyle_dashed") },
  244. { value: "dotted", text: t("labels.strokeStyle_dotted") },
  245. ]}
  246. value={getFormValue(
  247. elements,
  248. appState,
  249. (element) => element.strokeStyle,
  250. appState.currentItemStrokeStyle,
  251. )}
  252. onChange={(value) => updateData(value)}
  253. />
  254. </fieldset>
  255. ),
  256. });
  257. export const actionChangeOpacity = register({
  258. name: "changeOpacity",
  259. perform: (elements, appState, value) => {
  260. return {
  261. elements: changeProperty(elements, appState, (el) =>
  262. newElementWith(el, {
  263. opacity: value,
  264. }),
  265. ),
  266. appState: { ...appState, currentItemOpacity: value },
  267. commitToHistory: true,
  268. };
  269. },
  270. PanelComponent: ({ elements, appState, updateData }) => (
  271. <label className="control-label">
  272. {t("labels.opacity")}
  273. <input
  274. type="range"
  275. min="0"
  276. max="100"
  277. step="10"
  278. onChange={(event) => updateData(+event.target.value)}
  279. onWheel={(event) => {
  280. event.stopPropagation();
  281. const target = event.target as HTMLInputElement;
  282. const STEP = 10;
  283. const MAX = 100;
  284. const MIN = 0;
  285. const value = +target.value;
  286. if (event.deltaY < 0 && value < MAX) {
  287. updateData(value + STEP);
  288. } else if (event.deltaY > 0 && value > MIN) {
  289. updateData(value - STEP);
  290. }
  291. }}
  292. value={
  293. getFormValue(
  294. elements,
  295. appState,
  296. (element) => element.opacity,
  297. appState.currentItemOpacity,
  298. ) ?? undefined
  299. }
  300. />
  301. </label>
  302. ),
  303. });
  304. export const actionChangeFontSize = register({
  305. name: "changeFontSize",
  306. perform: (elements, appState, value) => {
  307. return {
  308. elements: changeProperty(elements, appState, (el) => {
  309. if (isTextElement(el)) {
  310. const element: ExcalidrawTextElement = newElementWith(el, {
  311. fontSize: value,
  312. });
  313. redrawTextBoundingBox(element);
  314. return element;
  315. }
  316. return el;
  317. }),
  318. appState: {
  319. ...appState,
  320. currentItemFontSize: value,
  321. },
  322. commitToHistory: true,
  323. };
  324. },
  325. PanelComponent: ({ elements, appState, updateData }) => (
  326. <fieldset>
  327. <legend>{t("labels.fontSize")}</legend>
  328. <ButtonSelect
  329. group="font-size"
  330. options={[
  331. { value: 16, text: t("labels.small") },
  332. { value: 20, text: t("labels.medium") },
  333. { value: 28, text: t("labels.large") },
  334. { value: 36, text: t("labels.veryLarge") },
  335. ]}
  336. value={getFormValue(
  337. elements,
  338. appState,
  339. (element) => isTextElement(element) && element.fontSize,
  340. appState.currentItemFontSize || DEFAULT_FONT_SIZE,
  341. )}
  342. onChange={(value) => updateData(value)}
  343. />
  344. </fieldset>
  345. ),
  346. });
  347. export const actionChangeFontFamily = register({
  348. name: "changeFontFamily",
  349. perform: (elements, appState, value) => {
  350. return {
  351. elements: changeProperty(elements, appState, (el) => {
  352. if (isTextElement(el)) {
  353. const element: ExcalidrawTextElement = newElementWith(el, {
  354. fontFamily: value,
  355. });
  356. redrawTextBoundingBox(element);
  357. return element;
  358. }
  359. return el;
  360. }),
  361. appState: {
  362. ...appState,
  363. currentItemFontFamily: value,
  364. },
  365. commitToHistory: true,
  366. };
  367. },
  368. PanelComponent: ({ elements, appState, updateData }) => {
  369. const options: { value: FontFamily; text: string }[] = [
  370. { value: 1, text: t("labels.handDrawn") },
  371. { value: 2, text: t("labels.normal") },
  372. { value: 3, text: t("labels.code") },
  373. ];
  374. return (
  375. <fieldset>
  376. <legend>{t("labels.fontFamily")}</legend>
  377. <ButtonSelect<FontFamily | false>
  378. group="font-family"
  379. options={options}
  380. value={getFormValue(
  381. elements,
  382. appState,
  383. (element) => isTextElement(element) && element.fontFamily,
  384. appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
  385. )}
  386. onChange={(value) => updateData(value)}
  387. />
  388. </fieldset>
  389. );
  390. },
  391. });
  392. export const actionChangeTextAlign = register({
  393. name: "changeTextAlign",
  394. perform: (elements, appState, value) => {
  395. return {
  396. elements: changeProperty(elements, appState, (el) => {
  397. if (isTextElement(el)) {
  398. const element: ExcalidrawTextElement = newElementWith(el, {
  399. textAlign: value,
  400. });
  401. redrawTextBoundingBox(element);
  402. return element;
  403. }
  404. return el;
  405. }),
  406. appState: {
  407. ...appState,
  408. currentItemTextAlign: value,
  409. },
  410. commitToHistory: true,
  411. };
  412. },
  413. PanelComponent: ({ elements, appState, updateData }) => (
  414. <fieldset>
  415. <legend>{t("labels.textAlign")}</legend>
  416. <ButtonSelect<TextAlign | false>
  417. group="text-align"
  418. options={[
  419. { value: "left", text: t("labels.left") },
  420. { value: "center", text: t("labels.center") },
  421. { value: "right", text: t("labels.right") },
  422. ]}
  423. value={getFormValue(
  424. elements,
  425. appState,
  426. (element) => isTextElement(element) && element.textAlign,
  427. appState.currentItemTextAlign,
  428. )}
  429. onChange={(value) => updateData(value)}
  430. />
  431. </fieldset>
  432. ),
  433. });