textElement.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824
  1. import { getFontString, arrayToMap, isTestEnv } from "../utils";
  2. import {
  3. ExcalidrawElement,
  4. ExcalidrawTextContainer,
  5. ExcalidrawTextElement,
  6. ExcalidrawTextElementWithContainer,
  7. FontString,
  8. NonDeletedExcalidrawElement,
  9. } from "./types";
  10. import { mutateElement } from "./mutateElement";
  11. import { BOUND_TEXT_PADDING, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants";
  12. import { MaybeTransformHandleType } from "./transformHandles";
  13. import Scene from "../scene/Scene";
  14. import { isTextElement } from ".";
  15. import {
  16. isBoundToContainer,
  17. isImageElement,
  18. isArrowElement,
  19. } from "./typeChecks";
  20. import { LinearElementEditor } from "./linearElementEditor";
  21. import { AppState } from "../types";
  22. import { isTextBindableContainer } from "./typeChecks";
  23. import { getElementAbsoluteCoords } from "../element";
  24. import { getSelectedElements } from "../scene";
  25. import { isHittingElementNotConsideringBoundingBox } from "./collision";
  26. import {
  27. resetOriginalContainerCache,
  28. updateOriginalContainerCache,
  29. } from "./textWysiwyg";
  30. export const normalizeText = (text: string) => {
  31. return (
  32. text
  33. // replace tabs with spaces so they render and measure correctly
  34. .replace(/\t/g, " ")
  35. // normalize newlines
  36. .replace(/\r?\n|\r/g, "\n")
  37. );
  38. };
  39. export const redrawTextBoundingBox = (
  40. textElement: ExcalidrawTextElement,
  41. container: ExcalidrawElement | null,
  42. ) => {
  43. let maxWidth = undefined;
  44. const boundTextUpdates = {
  45. x: textElement.x,
  46. y: textElement.y,
  47. text: textElement.text,
  48. width: textElement.width,
  49. height: textElement.height,
  50. baseline: textElement.baseline,
  51. };
  52. boundTextUpdates.text = textElement.text;
  53. if (container) {
  54. maxWidth = getMaxContainerWidth(container);
  55. boundTextUpdates.text = wrapText(
  56. textElement.originalText,
  57. getFontString(textElement),
  58. maxWidth,
  59. );
  60. }
  61. const metrics = measureText(
  62. boundTextUpdates.text,
  63. getFontString(textElement),
  64. maxWidth,
  65. );
  66. boundTextUpdates.width = metrics.width;
  67. boundTextUpdates.height = metrics.height;
  68. boundTextUpdates.baseline = metrics.baseline;
  69. if (container) {
  70. if (isArrowElement(container)) {
  71. const centerX = textElement.x + textElement.width / 2;
  72. const centerY = textElement.y + textElement.height / 2;
  73. const diffWidth = metrics.width - textElement.width;
  74. const diffHeight = metrics.height - textElement.height;
  75. boundTextUpdates.x = centerY - (textElement.height + diffHeight) / 2;
  76. boundTextUpdates.y = centerX - (textElement.width + diffWidth) / 2;
  77. } else {
  78. const containerDims = getContainerDims(container);
  79. let maxContainerHeight = getMaxContainerHeight(container);
  80. let nextHeight = containerDims.height;
  81. if (metrics.height > maxContainerHeight) {
  82. nextHeight = computeContainerHeightForBoundText(
  83. container,
  84. metrics.height,
  85. );
  86. mutateElement(container, { height: nextHeight });
  87. maxContainerHeight = getMaxContainerHeight(container);
  88. updateOriginalContainerCache(container.id, nextHeight);
  89. }
  90. const updatedTextElement = {
  91. ...textElement,
  92. ...boundTextUpdates,
  93. } as ExcalidrawTextElementWithContainer;
  94. const { x, y } = computeBoundTextPosition(container, updatedTextElement);
  95. boundTextUpdates.x = x;
  96. boundTextUpdates.y = y;
  97. }
  98. }
  99. mutateElement(textElement, boundTextUpdates);
  100. };
  101. export const bindTextToShapeAfterDuplication = (
  102. sceneElements: ExcalidrawElement[],
  103. oldElements: ExcalidrawElement[],
  104. oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
  105. ): void => {
  106. const sceneElementMap = arrayToMap(sceneElements) as Map<
  107. ExcalidrawElement["id"],
  108. ExcalidrawElement
  109. >;
  110. oldElements.forEach((element) => {
  111. const newElementId = oldIdToDuplicatedId.get(element.id) as string;
  112. const boundTextElementId = getBoundTextElementId(element);
  113. if (boundTextElementId) {
  114. const newTextElementId = oldIdToDuplicatedId.get(boundTextElementId);
  115. if (newTextElementId) {
  116. const newContainer = sceneElementMap.get(newElementId);
  117. if (newContainer) {
  118. mutateElement(newContainer, {
  119. boundElements: (element.boundElements || [])
  120. .filter(
  121. (boundElement) =>
  122. boundElement.id !== newTextElementId &&
  123. boundElement.id !== boundTextElementId,
  124. )
  125. .concat({
  126. type: "text",
  127. id: newTextElementId,
  128. }),
  129. });
  130. }
  131. const newTextElement = sceneElementMap.get(newTextElementId);
  132. if (newTextElement && isTextElement(newTextElement)) {
  133. mutateElement(newTextElement, {
  134. containerId: newContainer ? newElementId : null,
  135. });
  136. }
  137. }
  138. }
  139. });
  140. };
  141. export const handleBindTextResize = (
  142. container: NonDeletedExcalidrawElement,
  143. transformHandleType: MaybeTransformHandleType,
  144. ) => {
  145. const boundTextElementId = getBoundTextElementId(container);
  146. if (!boundTextElementId) {
  147. return;
  148. }
  149. resetOriginalContainerCache(container.id);
  150. let textElement = Scene.getScene(container)!.getElement(
  151. boundTextElementId,
  152. ) as ExcalidrawTextElement;
  153. if (textElement && textElement.text) {
  154. if (!container) {
  155. return;
  156. }
  157. textElement = Scene.getScene(container)!.getElement(
  158. boundTextElementId,
  159. ) as ExcalidrawTextElement;
  160. let text = textElement.text;
  161. let nextHeight = textElement.height;
  162. let nextWidth = textElement.width;
  163. const containerDims = getContainerDims(container);
  164. const maxWidth = getMaxContainerWidth(container);
  165. const maxHeight = getMaxContainerHeight(container);
  166. let containerHeight = containerDims.height;
  167. let nextBaseLine = textElement.baseline;
  168. if (transformHandleType !== "n" && transformHandleType !== "s") {
  169. if (text) {
  170. text = wrapText(
  171. textElement.originalText,
  172. getFontString(textElement),
  173. maxWidth,
  174. );
  175. }
  176. const dimensions = measureText(
  177. text,
  178. getFontString(textElement),
  179. maxWidth,
  180. );
  181. nextHeight = dimensions.height;
  182. nextWidth = dimensions.width;
  183. nextBaseLine = dimensions.baseline;
  184. }
  185. // increase height in case text element height exceeds
  186. if (nextHeight > maxHeight) {
  187. containerHeight = computeContainerHeightForBoundText(
  188. container,
  189. nextHeight,
  190. );
  191. const diff = containerHeight - containerDims.height;
  192. // fix the y coord when resizing from ne/nw/n
  193. const updatedY =
  194. !isArrowElement(container) &&
  195. (transformHandleType === "ne" ||
  196. transformHandleType === "nw" ||
  197. transformHandleType === "n")
  198. ? container.y - diff
  199. : container.y;
  200. mutateElement(container, {
  201. height: containerHeight,
  202. y: updatedY,
  203. });
  204. }
  205. mutateElement(textElement, {
  206. text,
  207. width: nextWidth,
  208. height: nextHeight,
  209. baseline: nextBaseLine,
  210. });
  211. if (!isArrowElement(container)) {
  212. mutateElement(
  213. textElement,
  214. computeBoundTextPosition(
  215. container,
  216. textElement as ExcalidrawTextElementWithContainer,
  217. ),
  218. );
  219. }
  220. }
  221. };
  222. const computeBoundTextPosition = (
  223. container: ExcalidrawElement,
  224. boundTextElement: ExcalidrawTextElementWithContainer,
  225. ) => {
  226. const containerCoords = getContainerCoords(container);
  227. const maxContainerHeight = getMaxContainerHeight(container);
  228. const maxContainerWidth = getMaxContainerWidth(container);
  229. let x;
  230. let y;
  231. if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
  232. y = containerCoords.y;
  233. } else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
  234. y = containerCoords.y + (maxContainerHeight - boundTextElement.height);
  235. } else {
  236. y =
  237. containerCoords.y +
  238. (maxContainerHeight / 2 - boundTextElement.height / 2);
  239. }
  240. if (boundTextElement.textAlign === TEXT_ALIGN.LEFT) {
  241. x = containerCoords.x;
  242. } else if (boundTextElement.textAlign === TEXT_ALIGN.RIGHT) {
  243. x = containerCoords.x + (maxContainerWidth - boundTextElement.width);
  244. } else {
  245. x =
  246. containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
  247. }
  248. return { x, y };
  249. };
  250. // https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
  251. export const measureText = (
  252. text: string,
  253. font: FontString,
  254. maxWidth?: number | null,
  255. ) => {
  256. text = text
  257. .split("\n")
  258. // replace empty lines with single space because leading/trailing empty
  259. // lines would be stripped from computation
  260. .map((x) => x || " ")
  261. .join("\n");
  262. const container = document.createElement("div");
  263. container.style.position = "absolute";
  264. container.style.whiteSpace = "pre";
  265. container.style.font = font;
  266. container.style.minHeight = "1em";
  267. if (maxWidth) {
  268. const lineHeight = getApproxLineHeight(font);
  269. // since we are adding a span of width 1px later
  270. container.style.maxWidth = `${maxWidth + 1}px`;
  271. container.style.overflow = "hidden";
  272. container.style.wordBreak = "break-word";
  273. container.style.lineHeight = `${String(lineHeight)}px`;
  274. container.style.whiteSpace = "pre-wrap";
  275. }
  276. document.body.appendChild(container);
  277. container.innerText = text;
  278. const span = document.createElement("span");
  279. span.style.display = "inline-block";
  280. span.style.overflow = "hidden";
  281. span.style.width = "1px";
  282. span.style.height = "1px";
  283. container.appendChild(span);
  284. // Baseline is important for positioning text on canvas
  285. const baseline = span.offsetTop + span.offsetHeight;
  286. // since we are adding a span of width 1px
  287. const width = container.offsetWidth + 1;
  288. const height = container.offsetHeight;
  289. document.body.removeChild(container);
  290. if (isTestEnv()) {
  291. return { width, height, baseline, container };
  292. }
  293. return { width, height, baseline };
  294. };
  295. const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
  296. const cacheApproxLineHeight: { [key: FontString]: number } = {};
  297. export const getApproxLineHeight = (font: FontString) => {
  298. if (cacheApproxLineHeight[font]) {
  299. return cacheApproxLineHeight[font];
  300. }
  301. cacheApproxLineHeight[font] = measureText(DUMMY_TEXT, font, null).height;
  302. return cacheApproxLineHeight[font];
  303. };
  304. let canvas: HTMLCanvasElement | undefined;
  305. const getLineWidth = (text: string, font: FontString) => {
  306. if (!canvas) {
  307. canvas = document.createElement("canvas");
  308. }
  309. const canvas2dContext = canvas.getContext("2d")!;
  310. canvas2dContext.font = font;
  311. const metrics = canvas2dContext.measureText(text);
  312. // since in test env the canvas measureText algo
  313. // doesn't measure text and instead just returns number of
  314. // characters hence we assume that each letteris 10px
  315. if (isTestEnv()) {
  316. return metrics.width * 10;
  317. }
  318. // Since measureText behaves differently in different browsers
  319. // OS so considering a adjustment factor of 0.2
  320. const adjustmentFactor = 0.2;
  321. return metrics.width + adjustmentFactor;
  322. };
  323. export const getTextWidth = (text: string, font: FontString) => {
  324. const lines = text.split("\n");
  325. let width = 0;
  326. lines.forEach((line) => {
  327. width = Math.max(width, getLineWidth(line, font));
  328. });
  329. return width;
  330. };
  331. export const wrapText = (text: string, font: FontString, maxWidth: number) => {
  332. const lines: Array<string> = [];
  333. const originalLines = text.split("\n");
  334. const spaceWidth = getLineWidth(" ", font);
  335. const push = (str: string) => {
  336. if (str.trim()) {
  337. lines.push(str);
  338. }
  339. };
  340. originalLines.forEach((originalLine) => {
  341. const words = originalLine.split(" ");
  342. // This means its newline so push it
  343. if (words.length === 1 && words[0] === "") {
  344. lines.push(words[0]);
  345. return; // continue
  346. }
  347. let currentLine = "";
  348. let currentLineWidthTillNow = 0;
  349. let index = 0;
  350. while (index < words.length) {
  351. const currentWordWidth = getLineWidth(words[index], font);
  352. // Start breaking longer words exceeding max width
  353. if (currentWordWidth >= maxWidth) {
  354. // push current line since the current word exceeds the max width
  355. // so will be appended in next line
  356. push(currentLine);
  357. currentLine = "";
  358. currentLineWidthTillNow = 0;
  359. while (words[index].length > 0) {
  360. const currentChar = String.fromCodePoint(
  361. words[index].codePointAt(0)!,
  362. );
  363. const width = charWidth.calculate(currentChar, font);
  364. currentLineWidthTillNow += width;
  365. words[index] = words[index].slice(currentChar.length);
  366. if (currentLineWidthTillNow >= maxWidth) {
  367. // only remove last trailing space which we have added when joining words
  368. if (currentLine.slice(-1) === " ") {
  369. currentLine = currentLine.slice(0, -1);
  370. }
  371. push(currentLine);
  372. currentLine = currentChar;
  373. currentLineWidthTillNow = width;
  374. } else {
  375. currentLine += currentChar;
  376. }
  377. }
  378. // push current line if appending space exceeds max width
  379. if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
  380. push(currentLine);
  381. currentLine = "";
  382. currentLineWidthTillNow = 0;
  383. } else {
  384. // space needs to be appended before next word
  385. // as currentLine contains chars which couldn't be appended
  386. // to previous line
  387. currentLine += " ";
  388. currentLineWidthTillNow += spaceWidth;
  389. }
  390. index++;
  391. } else {
  392. // Start appending words in a line till max width reached
  393. while (currentLineWidthTillNow < maxWidth && index < words.length) {
  394. const word = words[index];
  395. currentLineWidthTillNow = getLineWidth(currentLine + word, font);
  396. if (currentLineWidthTillNow >= maxWidth) {
  397. push(currentLine);
  398. currentLineWidthTillNow = 0;
  399. currentLine = "";
  400. break;
  401. }
  402. index++;
  403. currentLine += `${word} `;
  404. // Push the word if appending space exceeds max width
  405. if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
  406. const word = currentLine.slice(0, -1);
  407. push(word);
  408. currentLine = "";
  409. currentLineWidthTillNow = 0;
  410. break;
  411. }
  412. }
  413. if (currentLineWidthTillNow === maxWidth) {
  414. currentLine = "";
  415. currentLineWidthTillNow = 0;
  416. }
  417. }
  418. }
  419. if (currentLine) {
  420. // only remove last trailing space which we have added when joining words
  421. if (currentLine.slice(-1) === " ") {
  422. currentLine = currentLine.slice(0, -1);
  423. }
  424. push(currentLine);
  425. }
  426. });
  427. return lines.join("\n");
  428. };
  429. export const charWidth = (() => {
  430. const cachedCharWidth: { [key: FontString]: Array<number> } = {};
  431. const calculate = (char: string, font: FontString) => {
  432. const ascii = char.charCodeAt(0);
  433. if (!cachedCharWidth[font]) {
  434. cachedCharWidth[font] = [];
  435. }
  436. if (!cachedCharWidth[font][ascii]) {
  437. const width = getLineWidth(char, font);
  438. cachedCharWidth[font][ascii] = width;
  439. }
  440. return cachedCharWidth[font][ascii];
  441. };
  442. const getCache = (font: FontString) => {
  443. return cachedCharWidth[font];
  444. };
  445. return {
  446. calculate,
  447. getCache,
  448. };
  449. })();
  450. export const getApproxMinLineWidth = (font: FontString) => {
  451. const maxCharWidth = getMaxCharWidth(font);
  452. if (maxCharWidth === 0) {
  453. return (
  454. measureText(DUMMY_TEXT.split("").join("\n"), font).width +
  455. BOUND_TEXT_PADDING * 2
  456. );
  457. }
  458. return maxCharWidth + BOUND_TEXT_PADDING * 2;
  459. };
  460. export const getApproxMinLineHeight = (font: FontString) => {
  461. return getApproxLineHeight(font) + BOUND_TEXT_PADDING * 2;
  462. };
  463. export const getMinCharWidth = (font: FontString) => {
  464. const cache = charWidth.getCache(font);
  465. if (!cache) {
  466. return 0;
  467. }
  468. const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
  469. return Math.min(...cacheWithOutEmpty);
  470. };
  471. export const getMaxCharWidth = (font: FontString) => {
  472. const cache = charWidth.getCache(font);
  473. if (!cache) {
  474. return 0;
  475. }
  476. const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
  477. return Math.max(...cacheWithOutEmpty);
  478. };
  479. export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
  480. // Generally lower case is used so converting to lower case
  481. const dummyText = DUMMY_TEXT.toLocaleLowerCase();
  482. const batchLength = 6;
  483. let index = 0;
  484. let widthTillNow = 0;
  485. let str = "";
  486. while (widthTillNow <= width) {
  487. const batch = dummyText.substr(index, index + batchLength);
  488. str += batch;
  489. widthTillNow += getLineWidth(str, font);
  490. if (index === dummyText.length - 1) {
  491. index = 0;
  492. }
  493. index = index + batchLength;
  494. }
  495. while (widthTillNow > width) {
  496. str = str.substr(0, str.length - 1);
  497. widthTillNow = getLineWidth(str, font);
  498. }
  499. return str.length;
  500. };
  501. export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
  502. return container?.boundElements?.length
  503. ? container?.boundElements?.filter((ele) => ele.type === "text")[0]?.id ||
  504. null
  505. : null;
  506. };
  507. export const getBoundTextElement = (element: ExcalidrawElement | null) => {
  508. if (!element) {
  509. return null;
  510. }
  511. const boundTextElementId = getBoundTextElementId(element);
  512. if (boundTextElementId) {
  513. return (
  514. (Scene.getScene(element)?.getElement(
  515. boundTextElementId,
  516. ) as ExcalidrawTextElementWithContainer) || null
  517. );
  518. }
  519. return null;
  520. };
  521. export const getContainerElement = (
  522. element:
  523. | (ExcalidrawElement & {
  524. containerId: ExcalidrawElement["id"] | null;
  525. })
  526. | null,
  527. ) => {
  528. if (!element) {
  529. return null;
  530. }
  531. if (element.containerId) {
  532. return Scene.getScene(element)?.getElement(element.containerId) || null;
  533. }
  534. return null;
  535. };
  536. export const getContainerDims = (element: ExcalidrawElement) => {
  537. const MIN_WIDTH = 300;
  538. if (isArrowElement(element)) {
  539. const width = Math.max(element.width, MIN_WIDTH);
  540. const height = element.height;
  541. return { width, height };
  542. }
  543. return { width: element.width, height: element.height };
  544. };
  545. export const getContainerCenter = (
  546. container: ExcalidrawElement,
  547. appState: AppState,
  548. ) => {
  549. if (!isArrowElement(container)) {
  550. return {
  551. x: container.x + container.width / 2,
  552. y: container.y + container.height / 2,
  553. };
  554. }
  555. const points = LinearElementEditor.getPointsGlobalCoordinates(container);
  556. if (points.length % 2 === 1) {
  557. const index = Math.floor(container.points.length / 2);
  558. const midPoint = LinearElementEditor.getPointGlobalCoordinates(
  559. container,
  560. container.points[index],
  561. );
  562. return { x: midPoint[0], y: midPoint[1] };
  563. }
  564. const index = container.points.length / 2 - 1;
  565. let midSegmentMidpoint = LinearElementEditor.getEditorMidPoints(
  566. container,
  567. appState,
  568. )[index];
  569. if (!midSegmentMidpoint) {
  570. midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
  571. container,
  572. points[index],
  573. points[index + 1],
  574. index + 1,
  575. );
  576. }
  577. return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
  578. };
  579. export const getContainerCoords = (container: NonDeletedExcalidrawElement) => {
  580. let offsetX = BOUND_TEXT_PADDING;
  581. let offsetY = BOUND_TEXT_PADDING;
  582. if (container.type === "ellipse") {
  583. // The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172
  584. offsetX += (container.width / 2) * (1 - Math.sqrt(2) / 2);
  585. offsetY += (container.height / 2) * (1 - Math.sqrt(2) / 2);
  586. }
  587. // The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6265
  588. if (container.type === "diamond") {
  589. offsetX += container.width / 4;
  590. offsetY += container.height / 4;
  591. }
  592. return {
  593. x: container.x + offsetX,
  594. y: container.y + offsetY,
  595. };
  596. };
  597. export const getTextElementAngle = (textElement: ExcalidrawTextElement) => {
  598. const container = getContainerElement(textElement);
  599. if (!container || isArrowElement(container)) {
  600. return textElement.angle;
  601. }
  602. return container.angle;
  603. };
  604. export const getBoundTextElementOffset = (
  605. boundTextElement: ExcalidrawTextElement | null,
  606. ) => {
  607. const container = getContainerElement(boundTextElement);
  608. if (!container || !boundTextElement) {
  609. return 0;
  610. }
  611. if (isArrowElement(container)) {
  612. return BOUND_TEXT_PADDING * 8;
  613. }
  614. return BOUND_TEXT_PADDING;
  615. };
  616. export const getBoundTextElementPosition = (
  617. container: ExcalidrawElement,
  618. boundTextElement: ExcalidrawTextElementWithContainer,
  619. ) => {
  620. if (isArrowElement(container)) {
  621. return LinearElementEditor.getBoundTextElementPosition(
  622. container,
  623. boundTextElement,
  624. );
  625. }
  626. };
  627. export const shouldAllowVerticalAlign = (
  628. selectedElements: NonDeletedExcalidrawElement[],
  629. ) => {
  630. return selectedElements.some((element) => {
  631. const hasBoundContainer = isBoundToContainer(element);
  632. if (hasBoundContainer) {
  633. const container = getContainerElement(element);
  634. if (isTextElement(element) && isArrowElement(container)) {
  635. return false;
  636. }
  637. return true;
  638. }
  639. const boundTextElement = getBoundTextElement(element);
  640. if (boundTextElement) {
  641. if (isArrowElement(element)) {
  642. return false;
  643. }
  644. return true;
  645. }
  646. return false;
  647. });
  648. };
  649. export const getTextBindableContainerAtPosition = (
  650. elements: readonly ExcalidrawElement[],
  651. appState: AppState,
  652. x: number,
  653. y: number,
  654. ): ExcalidrawTextContainer | null => {
  655. const selectedElements = getSelectedElements(elements, appState);
  656. if (selectedElements.length === 1) {
  657. return isTextBindableContainer(selectedElements[0], false)
  658. ? selectedElements[0]
  659. : null;
  660. }
  661. let hitElement = null;
  662. // We need to to hit testing from front (end of the array) to back (beginning of the array)
  663. for (let index = elements.length - 1; index >= 0; --index) {
  664. if (elements[index].isDeleted) {
  665. continue;
  666. }
  667. const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]);
  668. if (
  669. isArrowElement(elements[index]) &&
  670. isHittingElementNotConsideringBoundingBox(elements[index], appState, [
  671. x,
  672. y,
  673. ])
  674. ) {
  675. hitElement = elements[index];
  676. break;
  677. } else if (x1 < x && x < x2 && y1 < y && y < y2) {
  678. hitElement = elements[index];
  679. break;
  680. }
  681. }
  682. return isTextBindableContainer(hitElement, false) ? hitElement : null;
  683. };
  684. export const isValidTextContainer = (element: ExcalidrawElement) => {
  685. return (
  686. element.type === "rectangle" ||
  687. element.type === "ellipse" ||
  688. element.type === "diamond" ||
  689. isImageElement(element) ||
  690. isArrowElement(element)
  691. );
  692. };
  693. export const computeContainerHeightForBoundText = (
  694. container: NonDeletedExcalidrawElement,
  695. boundTextElementHeight: number,
  696. ) => {
  697. if (container.type === "ellipse") {
  698. return Math.round((boundTextElementHeight / Math.sqrt(2)) * 2);
  699. }
  700. if (isArrowElement(container)) {
  701. return boundTextElementHeight + BOUND_TEXT_PADDING * 8 * 2;
  702. }
  703. if (container.type === "diamond") {
  704. return 2 * boundTextElementHeight;
  705. }
  706. return boundTextElementHeight + BOUND_TEXT_PADDING * 2;
  707. };
  708. export const getMaxContainerWidth = (container: ExcalidrawElement) => {
  709. const width = getContainerDims(container).width;
  710. if (isArrowElement(container)) {
  711. const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2;
  712. if (containerWidth <= 0) {
  713. const boundText = getBoundTextElement(container);
  714. if (boundText) {
  715. return boundText.width;
  716. }
  717. return BOUND_TEXT_PADDING * 8 * 2;
  718. }
  719. return containerWidth;
  720. }
  721. if (container.type === "ellipse") {
  722. // The width of the largest rectangle inscribed inside an ellipse is
  723. // Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from
  724. // equation of an ellipse -https://github.com/excalidraw/excalidraw/pull/6172
  725. return Math.round((width / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
  726. }
  727. if (container.type === "diamond") {
  728. // The width of the largest rectangle inscribed inside a rhombus is
  729. // Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265
  730. return Math.round(width / 2) - BOUND_TEXT_PADDING * 2;
  731. }
  732. return width - BOUND_TEXT_PADDING * 2;
  733. };
  734. export const getMaxContainerHeight = (container: ExcalidrawElement) => {
  735. const height = getContainerDims(container).height;
  736. if (isArrowElement(container)) {
  737. const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
  738. if (containerHeight <= 0) {
  739. const boundText = getBoundTextElement(container);
  740. if (boundText) {
  741. return boundText.height;
  742. }
  743. return BOUND_TEXT_PADDING * 8 * 2;
  744. }
  745. return height;
  746. }
  747. if (container.type === "ellipse") {
  748. // The height of the largest rectangle inscribed inside an ellipse is
  749. // Math.round((ellipse.height / 2) * Math.sqrt(2)) which is derived from
  750. // equation of an ellipse - https://github.com/excalidraw/excalidraw/pull/6172
  751. return Math.round((height / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
  752. }
  753. if (container.type === "diamond") {
  754. // The height of the largest rectangle inscribed inside a rhombus is
  755. // Math.round(height / 2) - https://github.com/excalidraw/excalidraw/pull/6265
  756. return Math.round(height / 2) - BOUND_TEXT_PADDING * 2;
  757. }
  758. return height - BOUND_TEXT_PADDING * 2;
  759. };