actionBoundText.tsx 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. import {
  2. BOUND_TEXT_PADDING,
  3. ROUNDNESS,
  4. VERTICAL_ALIGN,
  5. TEXT_ALIGN,
  6. } from "../constants";
  7. import { isTextElement, newElement } from "../element";
  8. import { mutateElement } from "../element/mutateElement";
  9. import {
  10. computeBoundTextPosition,
  11. computeContainerDimensionForBoundText,
  12. getBoundTextElement,
  13. measureText,
  14. redrawTextBoundingBox,
  15. } from "../element/textElement";
  16. import {
  17. getOriginalContainerHeightFromCache,
  18. resetOriginalContainerCache,
  19. updateOriginalContainerCache,
  20. } from "../element/textWysiwyg";
  21. import {
  22. hasBoundTextElement,
  23. isTextBindableContainer,
  24. isUsingAdaptiveRadius,
  25. } from "../element/typeChecks";
  26. import {
  27. ExcalidrawElement,
  28. ExcalidrawLinearElement,
  29. ExcalidrawTextContainer,
  30. ExcalidrawTextElement,
  31. } from "../element/types";
  32. import { AppState } from "../types";
  33. import { Mutable } from "../utility-types";
  34. import { getFontString } from "../utils";
  35. import { register } from "./register";
  36. export const actionUnbindText = register({
  37. name: "unbindText",
  38. contextItemLabel: "labels.unbindText",
  39. trackEvent: { category: "element" },
  40. predicate: (elements, appState, _, app) => {
  41. const selectedElements = app.scene.getSelectedElements(appState);
  42. return selectedElements.some((element) => hasBoundTextElement(element));
  43. },
  44. perform: (elements, appState, _, app) => {
  45. const selectedElements = app.scene.getSelectedElements(appState);
  46. selectedElements.forEach((element) => {
  47. const boundTextElement = getBoundTextElement(element);
  48. if (boundTextElement) {
  49. const { width, height, baseline } = measureText(
  50. boundTextElement.originalText,
  51. getFontString(boundTextElement),
  52. boundTextElement.lineHeight,
  53. );
  54. const originalContainerHeight = getOriginalContainerHeightFromCache(
  55. element.id,
  56. );
  57. resetOriginalContainerCache(element.id);
  58. const { x, y } = computeBoundTextPosition(element, boundTextElement);
  59. mutateElement(boundTextElement as ExcalidrawTextElement, {
  60. containerId: null,
  61. width,
  62. height,
  63. baseline,
  64. text: boundTextElement.originalText,
  65. x,
  66. y,
  67. });
  68. mutateElement(element, {
  69. boundElements: element.boundElements?.filter(
  70. (ele) => ele.id !== boundTextElement.id,
  71. ),
  72. height: originalContainerHeight
  73. ? originalContainerHeight
  74. : element.height,
  75. });
  76. }
  77. });
  78. return {
  79. elements,
  80. appState,
  81. commitToHistory: true,
  82. };
  83. },
  84. });
  85. export const actionBindText = register({
  86. name: "bindText",
  87. contextItemLabel: "labels.bindText",
  88. trackEvent: { category: "element" },
  89. predicate: (elements, appState, _, app) => {
  90. const selectedElements = app.scene.getSelectedElements(appState);
  91. if (selectedElements.length === 2) {
  92. const textElement =
  93. isTextElement(selectedElements[0]) ||
  94. isTextElement(selectedElements[1]);
  95. let bindingContainer;
  96. if (isTextBindableContainer(selectedElements[0])) {
  97. bindingContainer = selectedElements[0];
  98. } else if (isTextBindableContainer(selectedElements[1])) {
  99. bindingContainer = selectedElements[1];
  100. }
  101. if (
  102. textElement &&
  103. bindingContainer &&
  104. getBoundTextElement(bindingContainer) === null
  105. ) {
  106. return true;
  107. }
  108. }
  109. return false;
  110. },
  111. perform: (elements, appState, _, app) => {
  112. const selectedElements = app.scene.getSelectedElements(appState);
  113. let textElement: ExcalidrawTextElement;
  114. let container: ExcalidrawTextContainer;
  115. if (
  116. isTextElement(selectedElements[0]) &&
  117. isTextBindableContainer(selectedElements[1])
  118. ) {
  119. textElement = selectedElements[0];
  120. container = selectedElements[1];
  121. } else {
  122. textElement = selectedElements[1] as ExcalidrawTextElement;
  123. container = selectedElements[0] as ExcalidrawTextContainer;
  124. }
  125. mutateElement(textElement, {
  126. containerId: container.id,
  127. verticalAlign: VERTICAL_ALIGN.MIDDLE,
  128. textAlign: TEXT_ALIGN.CENTER,
  129. });
  130. mutateElement(container, {
  131. boundElements: (container.boundElements || []).concat({
  132. type: "text",
  133. id: textElement.id,
  134. }),
  135. });
  136. const originalContainerHeight = container.height;
  137. redrawTextBoundingBox(textElement, container);
  138. // overwritting the cache with original container height so
  139. // it can be restored when unbind
  140. updateOriginalContainerCache(container.id, originalContainerHeight);
  141. return {
  142. elements: pushTextAboveContainer(elements, container, textElement),
  143. appState: { ...appState, selectedElementIds: { [container.id]: true } },
  144. commitToHistory: true,
  145. };
  146. },
  147. });
  148. const pushTextAboveContainer = (
  149. elements: readonly ExcalidrawElement[],
  150. container: ExcalidrawElement,
  151. textElement: ExcalidrawTextElement,
  152. ) => {
  153. const updatedElements = elements.slice();
  154. const textElementIndex = updatedElements.findIndex(
  155. (ele) => ele.id === textElement.id,
  156. );
  157. updatedElements.splice(textElementIndex, 1);
  158. const containerIndex = updatedElements.findIndex(
  159. (ele) => ele.id === container.id,
  160. );
  161. updatedElements.splice(containerIndex + 1, 0, textElement);
  162. return updatedElements;
  163. };
  164. const pushContainerBelowText = (
  165. elements: readonly ExcalidrawElement[],
  166. container: ExcalidrawElement,
  167. textElement: ExcalidrawTextElement,
  168. ) => {
  169. const updatedElements = elements.slice();
  170. const containerIndex = updatedElements.findIndex(
  171. (ele) => ele.id === container.id,
  172. );
  173. updatedElements.splice(containerIndex, 1);
  174. const textElementIndex = updatedElements.findIndex(
  175. (ele) => ele.id === textElement.id,
  176. );
  177. updatedElements.splice(textElementIndex, 0, container);
  178. return updatedElements;
  179. };
  180. export const actionWrapTextInContainer = register({
  181. name: "wrapTextInContainer",
  182. contextItemLabel: "labels.createContainerFromText",
  183. trackEvent: { category: "element" },
  184. predicate: (elements, appState, _, app) => {
  185. const selectedElements = app.scene.getSelectedElements(appState);
  186. const areTextElements = selectedElements.every((el) => isTextElement(el));
  187. return selectedElements.length > 0 && areTextElements;
  188. },
  189. perform: (elements, appState, _, app) => {
  190. const selectedElements = app.scene.getSelectedElements(appState);
  191. let updatedElements: readonly ExcalidrawElement[] = elements.slice();
  192. const containerIds: Mutable<AppState["selectedElementIds"]> = {};
  193. for (const textElement of selectedElements) {
  194. if (isTextElement(textElement)) {
  195. const container = newElement({
  196. type: "rectangle",
  197. backgroundColor: appState.currentItemBackgroundColor,
  198. boundElements: [
  199. ...(textElement.boundElements || []),
  200. { id: textElement.id, type: "text" },
  201. ],
  202. angle: textElement.angle,
  203. fillStyle: appState.currentItemFillStyle,
  204. strokeColor: appState.currentItemStrokeColor,
  205. roughness: appState.currentItemRoughness,
  206. strokeWidth: appState.currentItemStrokeWidth,
  207. strokeStyle: appState.currentItemStrokeStyle,
  208. roundness:
  209. appState.currentItemRoundness === "round"
  210. ? {
  211. type: isUsingAdaptiveRadius("rectangle")
  212. ? ROUNDNESS.ADAPTIVE_RADIUS
  213. : ROUNDNESS.PROPORTIONAL_RADIUS,
  214. }
  215. : null,
  216. opacity: 100,
  217. locked: false,
  218. x: textElement.x - BOUND_TEXT_PADDING,
  219. y: textElement.y - BOUND_TEXT_PADDING,
  220. width: computeContainerDimensionForBoundText(
  221. textElement.width,
  222. "rectangle",
  223. ),
  224. height: computeContainerDimensionForBoundText(
  225. textElement.height,
  226. "rectangle",
  227. ),
  228. groupIds: textElement.groupIds,
  229. frameId: textElement.frameId,
  230. });
  231. // update bindings
  232. if (textElement.boundElements?.length) {
  233. const linearElementIds = textElement.boundElements
  234. .filter((ele) => ele.type === "arrow")
  235. .map((el) => el.id);
  236. const linearElements = updatedElements.filter((ele) =>
  237. linearElementIds.includes(ele.id),
  238. ) as ExcalidrawLinearElement[];
  239. linearElements.forEach((ele) => {
  240. let startBinding = ele.startBinding;
  241. let endBinding = ele.endBinding;
  242. if (startBinding?.elementId === textElement.id) {
  243. startBinding = {
  244. ...startBinding,
  245. elementId: container.id,
  246. };
  247. }
  248. if (endBinding?.elementId === textElement.id) {
  249. endBinding = { ...endBinding, elementId: container.id };
  250. }
  251. if (startBinding || endBinding) {
  252. mutateElement(ele, { startBinding, endBinding }, false);
  253. }
  254. });
  255. }
  256. mutateElement(
  257. textElement,
  258. {
  259. containerId: container.id,
  260. verticalAlign: VERTICAL_ALIGN.MIDDLE,
  261. boundElements: null,
  262. textAlign: TEXT_ALIGN.CENTER,
  263. },
  264. false,
  265. );
  266. redrawTextBoundingBox(textElement, container);
  267. updatedElements = pushContainerBelowText(
  268. [...updatedElements, container],
  269. container,
  270. textElement,
  271. );
  272. containerIds[container.id] = true;
  273. }
  274. }
  275. return {
  276. elements: updatedElements,
  277. appState: {
  278. ...appState,
  279. selectedElementIds: containerIds,
  280. },
  281. commitToHistory: true,
  282. };
  283. },
  284. });