textElement.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905
  1. import { getFontString, arrayToMap, isTestEnv, normalizeEOL } from "../utils";
  2. import type {
  3. ElementsMap,
  4. ExcalidrawElement,
  5. ExcalidrawElementType,
  6. ExcalidrawTextContainer,
  7. ExcalidrawTextElement,
  8. ExcalidrawTextElementWithContainer,
  9. FontString,
  10. NonDeletedExcalidrawElement,
  11. } from "./types";
  12. import { mutateElement } from "./mutateElement";
  13. import {
  14. ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
  15. ARROW_LABEL_WIDTH_FRACTION,
  16. BOUND_TEXT_PADDING,
  17. DEFAULT_FONT_FAMILY,
  18. DEFAULT_FONT_SIZE,
  19. TEXT_ALIGN,
  20. VERTICAL_ALIGN,
  21. } from "../constants";
  22. import type { MaybeTransformHandleType } from "./transformHandles";
  23. import { isTextElement } from ".";
  24. import { isBoundToContainer, isArrowElement } from "./typeChecks";
  25. import { LinearElementEditor } from "./linearElementEditor";
  26. import type { AppState } from "../types";
  27. import {
  28. resetOriginalContainerCache,
  29. updateOriginalContainerCache,
  30. } from "./containerCache";
  31. import type { ExtractSetType } from "../utility-types";
  32. export const normalizeText = (text: string) => {
  33. return (
  34. normalizeEOL(text)
  35. // replace tabs with spaces so they render and measure correctly
  36. .replace(/\t/g, " ")
  37. );
  38. };
  39. const splitIntoLines = (text: string) => {
  40. return normalizeText(text).split("\n");
  41. };
  42. export const redrawTextBoundingBox = (
  43. textElement: ExcalidrawTextElement,
  44. container: ExcalidrawElement | null,
  45. elementsMap: ElementsMap,
  46. informMutation = true,
  47. ) => {
  48. let maxWidth = undefined;
  49. const boundTextUpdates = {
  50. x: textElement.x,
  51. y: textElement.y,
  52. text: textElement.text,
  53. width: textElement.width,
  54. height: textElement.height,
  55. angle: container?.angle ?? textElement.angle,
  56. };
  57. boundTextUpdates.text = textElement.text;
  58. if (container || !textElement.autoResize) {
  59. maxWidth = container
  60. ? getBoundTextMaxWidth(container, textElement)
  61. : textElement.width;
  62. boundTextUpdates.text = wrapText(
  63. textElement.originalText,
  64. getFontString(textElement),
  65. maxWidth,
  66. );
  67. }
  68. const metrics = measureText(
  69. boundTextUpdates.text,
  70. getFontString(textElement),
  71. textElement.lineHeight,
  72. );
  73. // Note: only update width for unwrapped text and bound texts (which always have autoResize set to true)
  74. if (textElement.autoResize) {
  75. boundTextUpdates.width = metrics.width;
  76. }
  77. boundTextUpdates.height = metrics.height;
  78. if (container) {
  79. const maxContainerHeight = getBoundTextMaxHeight(
  80. container,
  81. textElement as ExcalidrawTextElementWithContainer,
  82. );
  83. const maxContainerWidth = getBoundTextMaxWidth(container, textElement);
  84. if (!isArrowElement(container) && metrics.height > maxContainerHeight) {
  85. const nextHeight = computeContainerDimensionForBoundText(
  86. metrics.height,
  87. container.type,
  88. );
  89. mutateElement(container, { height: nextHeight }, informMutation);
  90. updateOriginalContainerCache(container.id, nextHeight);
  91. }
  92. if (metrics.width > maxContainerWidth) {
  93. const nextWidth = computeContainerDimensionForBoundText(
  94. metrics.width,
  95. container.type,
  96. );
  97. mutateElement(container, { width: nextWidth }, informMutation);
  98. }
  99. const updatedTextElement = {
  100. ...textElement,
  101. ...boundTextUpdates,
  102. } as ExcalidrawTextElementWithContainer;
  103. const { x, y } = computeBoundTextPosition(
  104. container,
  105. updatedTextElement,
  106. elementsMap,
  107. );
  108. boundTextUpdates.x = x;
  109. boundTextUpdates.y = y;
  110. }
  111. mutateElement(textElement, boundTextUpdates, informMutation);
  112. };
  113. export const bindTextToShapeAfterDuplication = (
  114. newElements: ExcalidrawElement[],
  115. oldElements: ExcalidrawElement[],
  116. oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
  117. ): void => {
  118. const newElementsMap = arrayToMap(newElements) as Map<
  119. ExcalidrawElement["id"],
  120. ExcalidrawElement
  121. >;
  122. oldElements.forEach((element) => {
  123. const newElementId = oldIdToDuplicatedId.get(element.id) as string;
  124. const boundTextElementId = getBoundTextElementId(element);
  125. if (boundTextElementId) {
  126. const newTextElementId = oldIdToDuplicatedId.get(boundTextElementId);
  127. if (newTextElementId) {
  128. const newContainer = newElementsMap.get(newElementId);
  129. if (newContainer) {
  130. mutateElement(newContainer, {
  131. boundElements: (element.boundElements || [])
  132. .filter(
  133. (boundElement) =>
  134. boundElement.id !== newTextElementId &&
  135. boundElement.id !== boundTextElementId,
  136. )
  137. .concat({
  138. type: "text",
  139. id: newTextElementId,
  140. }),
  141. });
  142. }
  143. const newTextElement = newElementsMap.get(newTextElementId);
  144. if (newTextElement && isTextElement(newTextElement)) {
  145. mutateElement(newTextElement, {
  146. containerId: newContainer ? newElementId : null,
  147. });
  148. }
  149. }
  150. }
  151. });
  152. };
  153. export const handleBindTextResize = (
  154. container: NonDeletedExcalidrawElement,
  155. elementsMap: ElementsMap,
  156. transformHandleType: MaybeTransformHandleType,
  157. shouldMaintainAspectRatio = false,
  158. ) => {
  159. const boundTextElementId = getBoundTextElementId(container);
  160. if (!boundTextElementId) {
  161. return;
  162. }
  163. resetOriginalContainerCache(container.id);
  164. const textElement = getBoundTextElement(container, elementsMap);
  165. if (textElement && textElement.text) {
  166. if (!container) {
  167. return;
  168. }
  169. let text = textElement.text;
  170. let nextHeight = textElement.height;
  171. let nextWidth = textElement.width;
  172. const maxWidth = getBoundTextMaxWidth(container, textElement);
  173. const maxHeight = getBoundTextMaxHeight(container, textElement);
  174. let containerHeight = container.height;
  175. if (
  176. shouldMaintainAspectRatio ||
  177. (transformHandleType !== "n" && transformHandleType !== "s")
  178. ) {
  179. if (text) {
  180. text = wrapText(
  181. textElement.originalText,
  182. getFontString(textElement),
  183. maxWidth,
  184. );
  185. }
  186. const metrics = measureText(
  187. text,
  188. getFontString(textElement),
  189. textElement.lineHeight,
  190. );
  191. nextHeight = metrics.height;
  192. nextWidth = metrics.width;
  193. }
  194. // increase height in case text element height exceeds
  195. if (nextHeight > maxHeight) {
  196. containerHeight = computeContainerDimensionForBoundText(
  197. nextHeight,
  198. container.type,
  199. );
  200. const diff = containerHeight - container.height;
  201. // fix the y coord when resizing from ne/nw/n
  202. const updatedY =
  203. !isArrowElement(container) &&
  204. (transformHandleType === "ne" ||
  205. transformHandleType === "nw" ||
  206. transformHandleType === "n")
  207. ? container.y - diff
  208. : container.y;
  209. mutateElement(container, {
  210. height: containerHeight,
  211. y: updatedY,
  212. });
  213. }
  214. mutateElement(textElement, {
  215. text,
  216. width: nextWidth,
  217. height: nextHeight,
  218. });
  219. if (!isArrowElement(container)) {
  220. mutateElement(
  221. textElement,
  222. computeBoundTextPosition(container, textElement, elementsMap),
  223. );
  224. }
  225. }
  226. };
  227. export const computeBoundTextPosition = (
  228. container: ExcalidrawElement,
  229. boundTextElement: ExcalidrawTextElementWithContainer,
  230. elementsMap: ElementsMap,
  231. ) => {
  232. if (isArrowElement(container)) {
  233. return LinearElementEditor.getBoundTextElementPosition(
  234. container,
  235. boundTextElement,
  236. elementsMap,
  237. );
  238. }
  239. const containerCoords = getContainerCoords(container);
  240. const maxContainerHeight = getBoundTextMaxHeight(container, boundTextElement);
  241. const maxContainerWidth = getBoundTextMaxWidth(container, boundTextElement);
  242. let x;
  243. let y;
  244. if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
  245. y = containerCoords.y;
  246. } else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
  247. y = containerCoords.y + (maxContainerHeight - boundTextElement.height);
  248. } else {
  249. y =
  250. containerCoords.y +
  251. (maxContainerHeight / 2 - boundTextElement.height / 2);
  252. }
  253. if (boundTextElement.textAlign === TEXT_ALIGN.LEFT) {
  254. x = containerCoords.x;
  255. } else if (boundTextElement.textAlign === TEXT_ALIGN.RIGHT) {
  256. x = containerCoords.x + (maxContainerWidth - boundTextElement.width);
  257. } else {
  258. x =
  259. containerCoords.x + (maxContainerWidth / 2 - boundTextElement.width / 2);
  260. }
  261. return { x, y };
  262. };
  263. export const measureText = (
  264. text: string,
  265. font: FontString,
  266. lineHeight: ExcalidrawTextElement["lineHeight"],
  267. forceAdvanceWidth?: true,
  268. ) => {
  269. const _text = text
  270. .split("\n")
  271. // replace empty lines with single space because leading/trailing empty
  272. // lines would be stripped from computation
  273. .map((x) => x || " ")
  274. .join("\n");
  275. const fontSize = parseFloat(font);
  276. const height = getTextHeight(_text, fontSize, lineHeight);
  277. const width = getTextWidth(_text, font, forceAdvanceWidth);
  278. return { width, height };
  279. };
  280. /**
  281. * To get unitless line-height (if unknown) we can calculate it by dividing
  282. * height-per-line by fontSize.
  283. */
  284. export const detectLineHeight = (textElement: ExcalidrawTextElement) => {
  285. const lineCount = splitIntoLines(textElement.text).length;
  286. return (textElement.height /
  287. lineCount /
  288. textElement.fontSize) as ExcalidrawTextElement["lineHeight"];
  289. };
  290. /**
  291. * We calculate the line height from the font size and the unitless line height,
  292. * aligning with the W3C spec.
  293. */
  294. export const getLineHeightInPx = (
  295. fontSize: ExcalidrawTextElement["fontSize"],
  296. lineHeight: ExcalidrawTextElement["lineHeight"],
  297. ) => {
  298. return fontSize * lineHeight;
  299. };
  300. // FIXME rename to getApproxMinContainerHeight
  301. export const getApproxMinLineHeight = (
  302. fontSize: ExcalidrawTextElement["fontSize"],
  303. lineHeight: ExcalidrawTextElement["lineHeight"],
  304. ) => {
  305. return getLineHeightInPx(fontSize, lineHeight) + BOUND_TEXT_PADDING * 2;
  306. };
  307. let canvas: HTMLCanvasElement | undefined;
  308. /**
  309. * @param forceAdvanceWidth use to force retrieve the "advance width" ~ `metrics.width`, instead of the actual boundind box width.
  310. *
  311. * > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
  312. *
  313. * We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for:
  314. * - text wrapping
  315. * - wysiwyg editor (+padding)
  316. *
  317. * Everything else should be based on the actual bounding box width.
  318. *
  319. * `Math.ceil` of the final width adds additional buffer which stabilizes slight wrapping incosistencies.
  320. */
  321. const getLineWidth = (
  322. text: string,
  323. font: FontString,
  324. forceAdvanceWidth?: true,
  325. ) => {
  326. if (!canvas) {
  327. canvas = document.createElement("canvas");
  328. }
  329. const canvas2dContext = canvas.getContext("2d")!;
  330. canvas2dContext.font = font;
  331. const metrics = canvas2dContext.measureText(text);
  332. const advanceWidth = metrics.width;
  333. // retrieve the actual bounding box width if these metrics are available (as of now > 95% coverage)
  334. if (
  335. !forceAdvanceWidth &&
  336. window.TextMetrics &&
  337. "actualBoundingBoxLeft" in window.TextMetrics.prototype &&
  338. "actualBoundingBoxRight" in window.TextMetrics.prototype
  339. ) {
  340. // could be negative, therefore getting the absolute value
  341. const actualWidth =
  342. Math.abs(metrics.actualBoundingBoxLeft) +
  343. Math.abs(metrics.actualBoundingBoxRight);
  344. // fallback to advance width if the actual width is zero, i.e. on text editing start
  345. // or when actual width does not respect whitespace chars, i.e. spaces
  346. // otherwise actual width should always be bigger
  347. return Math.max(actualWidth, advanceWidth);
  348. }
  349. // since in test env the canvas measureText algo
  350. // doesn't measure text and instead just returns number of
  351. // characters hence we assume that each letteris 10px
  352. if (isTestEnv()) {
  353. return advanceWidth * 10;
  354. }
  355. return advanceWidth;
  356. };
  357. export const getTextWidth = (
  358. text: string,
  359. font: FontString,
  360. forceAdvanceWidth?: true,
  361. ) => {
  362. const lines = splitIntoLines(text);
  363. let width = 0;
  364. lines.forEach((line) => {
  365. width = Math.max(width, getLineWidth(line, font, forceAdvanceWidth));
  366. });
  367. return width;
  368. };
  369. export const getTextHeight = (
  370. text: string,
  371. fontSize: number,
  372. lineHeight: ExcalidrawTextElement["lineHeight"],
  373. ) => {
  374. const lineCount = splitIntoLines(text).length;
  375. return getLineHeightInPx(fontSize, lineHeight) * lineCount;
  376. };
  377. export const parseTokens = (text: string) => {
  378. // Splitting words containing "-" as those are treated as separate words
  379. // by css wrapping algorithm eg non-profit => non-, profit
  380. const words = text.split("-");
  381. if (words.length > 1) {
  382. // non-proft org => ['non-', 'profit org']
  383. words.forEach((word, index) => {
  384. if (index !== words.length - 1) {
  385. words[index] = word += "-";
  386. }
  387. });
  388. }
  389. // Joining the words with space and splitting them again with space to get the
  390. // final list of tokens
  391. // ['non-', 'profit org'] =>,'non- proft org' => ['non-','profit','org']
  392. return words.join(" ").split(" ");
  393. };
  394. export const wrapText = (
  395. text: string,
  396. font: FontString,
  397. maxWidth: number,
  398. ): string => {
  399. // if maxWidth is not finite or NaN which can happen in case of bugs in
  400. // computation, we need to make sure we don't continue as we'll end up
  401. // in an infinite loop
  402. if (!Number.isFinite(maxWidth) || maxWidth < 0) {
  403. return text;
  404. }
  405. const lines: Array<string> = [];
  406. const originalLines = text.split("\n");
  407. const spaceAdvanceWidth = getLineWidth(" ", font, true);
  408. let currentLine = "";
  409. let currentLineWidthTillNow = 0;
  410. const push = (str: string) => {
  411. if (str.trim()) {
  412. lines.push(str);
  413. }
  414. };
  415. const resetParams = () => {
  416. currentLine = "";
  417. currentLineWidthTillNow = 0;
  418. };
  419. for (const originalLine of originalLines) {
  420. const currentLineWidth = getLineWidth(originalLine, font, true);
  421. // Push the line if its <= maxWidth
  422. if (currentLineWidth <= maxWidth) {
  423. lines.push(originalLine);
  424. continue;
  425. }
  426. const words = parseTokens(originalLine);
  427. resetParams();
  428. let index = 0;
  429. while (index < words.length) {
  430. const currentWordWidth = getLineWidth(words[index], font, true);
  431. // This will only happen when single word takes entire width
  432. if (currentWordWidth === maxWidth) {
  433. push(words[index]);
  434. index++;
  435. }
  436. // Start breaking longer words exceeding max width
  437. else if (currentWordWidth > maxWidth) {
  438. // push current line since the current word exceeds the max width
  439. // so will be appended in next line
  440. push(currentLine);
  441. resetParams();
  442. while (words[index].length > 0) {
  443. const currentChar = String.fromCodePoint(
  444. words[index].codePointAt(0)!,
  445. );
  446. const line = currentLine + currentChar;
  447. // use advance width instead of the actual width as it's closest to the browser wapping algo
  448. // use width of the whole line instead of calculating individual chars to accomodate for kerning
  449. const lineAdvanceWidth = getLineWidth(line, font, true);
  450. const charAdvanceWidth = charWidth.calculate(currentChar, font);
  451. currentLineWidthTillNow = lineAdvanceWidth;
  452. words[index] = words[index].slice(currentChar.length);
  453. if (currentLineWidthTillNow >= maxWidth) {
  454. push(currentLine);
  455. currentLine = currentChar;
  456. currentLineWidthTillNow = charAdvanceWidth;
  457. } else {
  458. currentLine = line;
  459. }
  460. }
  461. // push current line if appending space exceeds max width
  462. if (currentLineWidthTillNow + spaceAdvanceWidth >= maxWidth) {
  463. push(currentLine);
  464. resetParams();
  465. // space needs to be appended before next word
  466. // as currentLine contains chars which couldn't be appended
  467. // to previous line unless the line ends with hyphen to sync
  468. // with css word-wrap
  469. } else if (!currentLine.endsWith("-")) {
  470. currentLine += " ";
  471. currentLineWidthTillNow += spaceAdvanceWidth;
  472. }
  473. index++;
  474. } else {
  475. // Start appending words in a line till max width reached
  476. while (currentLineWidthTillNow < maxWidth && index < words.length) {
  477. const word = words[index];
  478. currentLineWidthTillNow = getLineWidth(
  479. currentLine + word,
  480. font,
  481. true,
  482. );
  483. if (currentLineWidthTillNow > maxWidth) {
  484. push(currentLine);
  485. resetParams();
  486. break;
  487. }
  488. index++;
  489. // if word ends with "-" then we don't need to add space
  490. // to sync with css word-wrap
  491. const shouldAppendSpace = !word.endsWith("-");
  492. currentLine += word;
  493. if (shouldAppendSpace) {
  494. currentLine += " ";
  495. }
  496. // Push the word if appending space exceeds max width
  497. if (currentLineWidthTillNow + spaceAdvanceWidth >= maxWidth) {
  498. if (shouldAppendSpace) {
  499. lines.push(currentLine.slice(0, -1));
  500. } else {
  501. lines.push(currentLine);
  502. }
  503. resetParams();
  504. break;
  505. }
  506. }
  507. }
  508. }
  509. if (currentLine.slice(-1) === " ") {
  510. // only remove last trailing space which we have added when joining words
  511. currentLine = currentLine.slice(0, -1);
  512. push(currentLine);
  513. }
  514. }
  515. return lines.join("\n");
  516. };
  517. export const charWidth = (() => {
  518. const cachedCharWidth: { [key: FontString]: Array<number> } = {};
  519. const calculate = (char: string, font: FontString) => {
  520. const ascii = char.charCodeAt(0);
  521. if (!cachedCharWidth[font]) {
  522. cachedCharWidth[font] = [];
  523. }
  524. if (!cachedCharWidth[font][ascii]) {
  525. const width = getLineWidth(char, font, true);
  526. cachedCharWidth[font][ascii] = width;
  527. }
  528. return cachedCharWidth[font][ascii];
  529. };
  530. const getCache = (font: FontString) => {
  531. return cachedCharWidth[font];
  532. };
  533. return {
  534. calculate,
  535. getCache,
  536. };
  537. })();
  538. const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
  539. // FIXME rename to getApproxMinContainerWidth
  540. export const getApproxMinLineWidth = (
  541. font: FontString,
  542. lineHeight: ExcalidrawTextElement["lineHeight"],
  543. ) => {
  544. const maxCharWidth = getMaxCharWidth(font);
  545. if (maxCharWidth === 0) {
  546. return (
  547. measureText(DUMMY_TEXT.split("").join("\n"), font, lineHeight).width +
  548. BOUND_TEXT_PADDING * 2
  549. );
  550. }
  551. return maxCharWidth + BOUND_TEXT_PADDING * 2;
  552. };
  553. export const getMinCharWidth = (font: FontString) => {
  554. const cache = charWidth.getCache(font);
  555. if (!cache) {
  556. return 0;
  557. }
  558. const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
  559. return Math.min(...cacheWithOutEmpty);
  560. };
  561. export const getMaxCharWidth = (font: FontString) => {
  562. const cache = charWidth.getCache(font);
  563. if (!cache) {
  564. return 0;
  565. }
  566. const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
  567. return Math.max(...cacheWithOutEmpty);
  568. };
  569. export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
  570. return container?.boundElements?.length
  571. ? container?.boundElements?.find((ele) => ele.type === "text")?.id || null
  572. : null;
  573. };
  574. export const getBoundTextElement = (
  575. element: ExcalidrawElement | null,
  576. elementsMap: ElementsMap,
  577. ) => {
  578. if (!element) {
  579. return null;
  580. }
  581. const boundTextElementId = getBoundTextElementId(element);
  582. if (boundTextElementId) {
  583. return (elementsMap.get(boundTextElementId) ||
  584. null) as ExcalidrawTextElementWithContainer | null;
  585. }
  586. return null;
  587. };
  588. export const getContainerElement = (
  589. element: ExcalidrawTextElement | null,
  590. elementsMap: ElementsMap,
  591. ): ExcalidrawTextContainer | null => {
  592. if (!element) {
  593. return null;
  594. }
  595. if (element.containerId) {
  596. return (elementsMap.get(element.containerId) ||
  597. null) as ExcalidrawTextContainer | null;
  598. }
  599. return null;
  600. };
  601. export const getContainerCenter = (
  602. container: ExcalidrawElement,
  603. appState: AppState,
  604. elementsMap: ElementsMap,
  605. ) => {
  606. if (!isArrowElement(container)) {
  607. return {
  608. x: container.x + container.width / 2,
  609. y: container.y + container.height / 2,
  610. };
  611. }
  612. const points = LinearElementEditor.getPointsGlobalCoordinates(
  613. container,
  614. elementsMap,
  615. );
  616. if (points.length % 2 === 1) {
  617. const index = Math.floor(container.points.length / 2);
  618. const midPoint = LinearElementEditor.getPointGlobalCoordinates(
  619. container,
  620. container.points[index],
  621. elementsMap,
  622. );
  623. return { x: midPoint[0], y: midPoint[1] };
  624. }
  625. const index = container.points.length / 2 - 1;
  626. let midSegmentMidpoint = LinearElementEditor.getEditorMidPoints(
  627. container,
  628. elementsMap,
  629. appState,
  630. )[index];
  631. if (!midSegmentMidpoint) {
  632. midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
  633. container,
  634. points[index],
  635. points[index + 1],
  636. index + 1,
  637. elementsMap,
  638. );
  639. }
  640. return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };
  641. };
  642. export const getContainerCoords = (container: NonDeletedExcalidrawElement) => {
  643. let offsetX = BOUND_TEXT_PADDING;
  644. let offsetY = BOUND_TEXT_PADDING;
  645. if (container.type === "ellipse") {
  646. // The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6172
  647. offsetX += (container.width / 2) * (1 - Math.sqrt(2) / 2);
  648. offsetY += (container.height / 2) * (1 - Math.sqrt(2) / 2);
  649. }
  650. // The derivation of coordinates is explained in https://github.com/excalidraw/excalidraw/pull/6265
  651. if (container.type === "diamond") {
  652. offsetX += container.width / 4;
  653. offsetY += container.height / 4;
  654. }
  655. return {
  656. x: container.x + offsetX,
  657. y: container.y + offsetY,
  658. };
  659. };
  660. export const getTextElementAngle = (
  661. textElement: ExcalidrawTextElement,
  662. container: ExcalidrawTextContainer | null,
  663. ) => {
  664. if (!container || isArrowElement(container)) {
  665. return textElement.angle;
  666. }
  667. return container.angle;
  668. };
  669. export const getBoundTextElementPosition = (
  670. container: ExcalidrawElement,
  671. boundTextElement: ExcalidrawTextElementWithContainer,
  672. elementsMap: ElementsMap,
  673. ) => {
  674. if (isArrowElement(container)) {
  675. return LinearElementEditor.getBoundTextElementPosition(
  676. container,
  677. boundTextElement,
  678. elementsMap,
  679. );
  680. }
  681. };
  682. export const shouldAllowVerticalAlign = (
  683. selectedElements: NonDeletedExcalidrawElement[],
  684. elementsMap: ElementsMap,
  685. ) => {
  686. return selectedElements.some((element) => {
  687. if (isBoundToContainer(element)) {
  688. const container = getContainerElement(element, elementsMap);
  689. if (isArrowElement(container)) {
  690. return false;
  691. }
  692. return true;
  693. }
  694. return false;
  695. });
  696. };
  697. export const suppportsHorizontalAlign = (
  698. selectedElements: NonDeletedExcalidrawElement[],
  699. elementsMap: ElementsMap,
  700. ) => {
  701. return selectedElements.some((element) => {
  702. if (isBoundToContainer(element)) {
  703. const container = getContainerElement(element, elementsMap);
  704. if (isArrowElement(container)) {
  705. return false;
  706. }
  707. return true;
  708. }
  709. return isTextElement(element);
  710. });
  711. };
  712. const VALID_CONTAINER_TYPES = new Set([
  713. "rectangle",
  714. "ellipse",
  715. "diamond",
  716. "arrow",
  717. ]);
  718. export const isValidTextContainer = (element: {
  719. type: ExcalidrawElementType;
  720. }) => VALID_CONTAINER_TYPES.has(element.type);
  721. export const computeContainerDimensionForBoundText = (
  722. dimension: number,
  723. containerType: ExtractSetType<typeof VALID_CONTAINER_TYPES>,
  724. ) => {
  725. dimension = Math.ceil(dimension);
  726. const padding = BOUND_TEXT_PADDING * 2;
  727. if (containerType === "ellipse") {
  728. return Math.round(((dimension + padding) / Math.sqrt(2)) * 2);
  729. }
  730. if (containerType === "arrow") {
  731. return dimension + padding * 8;
  732. }
  733. if (containerType === "diamond") {
  734. return 2 * (dimension + padding);
  735. }
  736. return dimension + padding;
  737. };
  738. export const getBoundTextMaxWidth = (
  739. container: ExcalidrawElement,
  740. boundTextElement: ExcalidrawTextElement | null,
  741. ) => {
  742. const { width } = container;
  743. if (isArrowElement(container)) {
  744. const minWidth =
  745. (boundTextElement?.fontSize ?? DEFAULT_FONT_SIZE) *
  746. ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO;
  747. return Math.max(ARROW_LABEL_WIDTH_FRACTION * width, minWidth);
  748. }
  749. if (container.type === "ellipse") {
  750. // The width of the largest rectangle inscribed inside an ellipse is
  751. // Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from
  752. // equation of an ellipse -https://github.com/excalidraw/excalidraw/pull/6172
  753. return Math.round((width / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
  754. }
  755. if (container.type === "diamond") {
  756. // The width of the largest rectangle inscribed inside a rhombus is
  757. // Math.round(width / 2) - https://github.com/excalidraw/excalidraw/pull/6265
  758. return Math.round(width / 2) - BOUND_TEXT_PADDING * 2;
  759. }
  760. return width - BOUND_TEXT_PADDING * 2;
  761. };
  762. export const getBoundTextMaxHeight = (
  763. container: ExcalidrawElement,
  764. boundTextElement: ExcalidrawTextElementWithContainer,
  765. ) => {
  766. const { height } = container;
  767. if (isArrowElement(container)) {
  768. const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
  769. if (containerHeight <= 0) {
  770. return boundTextElement.height;
  771. }
  772. return height;
  773. }
  774. if (container.type === "ellipse") {
  775. // The height of the largest rectangle inscribed inside an ellipse is
  776. // Math.round((ellipse.height / 2) * Math.sqrt(2)) which is derived from
  777. // equation of an ellipse - https://github.com/excalidraw/excalidraw/pull/6172
  778. return Math.round((height / 2) * Math.sqrt(2)) - BOUND_TEXT_PADDING * 2;
  779. }
  780. if (container.type === "diamond") {
  781. // The height of the largest rectangle inscribed inside a rhombus is
  782. // Math.round(height / 2) - https://github.com/excalidraw/excalidraw/pull/6265
  783. return Math.round(height / 2) - BOUND_TEXT_PADDING * 2;
  784. }
  785. return height - BOUND_TEXT_PADDING * 2;
  786. };
  787. export const isMeasureTextSupported = () => {
  788. const width = getTextWidth(
  789. DUMMY_TEXT,
  790. getFontString({
  791. fontSize: DEFAULT_FONT_SIZE,
  792. fontFamily: DEFAULT_FONT_FAMILY,
  793. }),
  794. );
  795. return width > 0;
  796. };
  797. export const getMinTextElementWidth = (
  798. font: FontString,
  799. lineHeight: ExcalidrawTextElement["lineHeight"],
  800. ) => {
  801. return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
  802. };
  803. /** retrieves text from text elements and concatenates to a single string */
  804. export const getTextFromElements = (
  805. elements: readonly ExcalidrawElement[],
  806. separator = "\n\n",
  807. ) => {
  808. const text = elements
  809. .reduce((acc: string[], element) => {
  810. if (isTextElement(element)) {
  811. acc.push(element.text);
  812. }
  813. return acc;
  814. }, [])
  815. .join(separator);
  816. return text;
  817. };