textElement.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. import { getFontString, arrayToMap, isTestEnv } from "../utils";
  2. import {
  3. ExcalidrawElement,
  4. ExcalidrawTextElement,
  5. ExcalidrawTextElementWithContainer,
  6. FontString,
  7. NonDeletedExcalidrawElement,
  8. } from "./types";
  9. import { mutateElement } from "./mutateElement";
  10. import { BOUND_TEXT_PADDING, TEXT_ALIGN, VERTICAL_ALIGN } from "../constants";
  11. import { MaybeTransformHandleType } from "./transformHandles";
  12. import Scene from "../scene/Scene";
  13. import { isTextElement } from ".";
  14. import { getMaxContainerHeight, getMaxContainerWidth } from "./newElement";
  15. import { isImageElement } from "./typeChecks";
  16. export const redrawTextBoundingBox = (
  17. textElement: ExcalidrawTextElement,
  18. container: ExcalidrawElement | null,
  19. ) => {
  20. let maxWidth = undefined;
  21. let text = textElement.text;
  22. if (container) {
  23. maxWidth = getMaxContainerWidth(container);
  24. text = wrapText(
  25. textElement.originalText,
  26. getFontString(textElement),
  27. maxWidth,
  28. );
  29. }
  30. const metrics = measureText(
  31. textElement.originalText,
  32. getFontString(textElement),
  33. maxWidth,
  34. );
  35. let coordY = textElement.y;
  36. let coordX = textElement.x;
  37. // Resize container and vertically center align the text
  38. if (container) {
  39. const containerDims = getContainerDims(container);
  40. let nextHeight = containerDims.height;
  41. if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
  42. coordY = container.y + BOUND_TEXT_PADDING;
  43. } else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
  44. coordY =
  45. container.y +
  46. containerDims.height -
  47. metrics.height -
  48. BOUND_TEXT_PADDING;
  49. } else {
  50. coordY = container.y + containerDims.height / 2 - metrics.height / 2;
  51. if (metrics.height > getMaxContainerHeight(container)) {
  52. nextHeight = metrics.height + BOUND_TEXT_PADDING * 2;
  53. coordY = container.y + nextHeight / 2 - metrics.height / 2;
  54. }
  55. }
  56. if (textElement.textAlign === TEXT_ALIGN.LEFT) {
  57. coordX = container.x + BOUND_TEXT_PADDING;
  58. } else if (textElement.textAlign === TEXT_ALIGN.RIGHT) {
  59. coordX =
  60. container.x + containerDims.width - metrics.width - BOUND_TEXT_PADDING;
  61. } else {
  62. coordX = container.x + container.width / 2 - metrics.width / 2;
  63. }
  64. mutateElement(container, { height: nextHeight });
  65. }
  66. mutateElement(textElement, {
  67. width: metrics.width,
  68. height: metrics.height,
  69. baseline: metrics.baseline,
  70. y: coordY,
  71. x: coordX,
  72. text,
  73. });
  74. };
  75. export const bindTextToShapeAfterDuplication = (
  76. sceneElements: ExcalidrawElement[],
  77. oldElements: ExcalidrawElement[],
  78. oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
  79. ): void => {
  80. const sceneElementMap = arrayToMap(sceneElements) as Map<
  81. ExcalidrawElement["id"],
  82. ExcalidrawElement
  83. >;
  84. oldElements.forEach((element) => {
  85. const newElementId = oldIdToDuplicatedId.get(element.id) as string;
  86. const boundTextElementId = getBoundTextElementId(element);
  87. if (boundTextElementId) {
  88. const newTextElementId = oldIdToDuplicatedId.get(boundTextElementId);
  89. if (newTextElementId) {
  90. const newContainer = sceneElementMap.get(newElementId);
  91. if (newContainer) {
  92. mutateElement(newContainer, {
  93. boundElements: element.boundElements?.concat({
  94. type: "text",
  95. id: newTextElementId,
  96. }),
  97. });
  98. }
  99. const newTextElement = sceneElementMap.get(newTextElementId);
  100. if (newTextElement && isTextElement(newTextElement)) {
  101. mutateElement(newTextElement, {
  102. containerId: newContainer ? newElementId : null,
  103. });
  104. }
  105. }
  106. }
  107. });
  108. };
  109. export const handleBindTextResize = (
  110. element: NonDeletedExcalidrawElement,
  111. transformHandleType: MaybeTransformHandleType,
  112. ) => {
  113. const boundTextElementId = getBoundTextElementId(element);
  114. if (boundTextElementId) {
  115. const textElement = Scene.getScene(element)!.getElement(
  116. boundTextElementId,
  117. ) as ExcalidrawTextElement;
  118. if (textElement && textElement.text) {
  119. if (!element) {
  120. return;
  121. }
  122. let text = textElement.text;
  123. let nextHeight = textElement.height;
  124. let nextWidth = textElement.width;
  125. let containerHeight = element.height;
  126. let nextBaseLine = textElement.baseline;
  127. if (transformHandleType !== "n" && transformHandleType !== "s") {
  128. if (text) {
  129. text = wrapText(
  130. textElement.originalText,
  131. getFontString(textElement),
  132. getMaxContainerWidth(element),
  133. );
  134. }
  135. const dimensions = measureText(
  136. text,
  137. getFontString(textElement),
  138. element.width,
  139. );
  140. nextHeight = dimensions.height;
  141. nextWidth = dimensions.width;
  142. nextBaseLine = dimensions.baseline;
  143. }
  144. // increase height in case text element height exceeds
  145. if (nextHeight > element.height - BOUND_TEXT_PADDING * 2) {
  146. containerHeight = nextHeight + BOUND_TEXT_PADDING * 2;
  147. const diff = containerHeight - element.height;
  148. // fix the y coord when resizing from ne/nw/n
  149. const updatedY =
  150. transformHandleType === "ne" ||
  151. transformHandleType === "nw" ||
  152. transformHandleType === "n"
  153. ? element.y - diff
  154. : element.y;
  155. mutateElement(element, {
  156. height: containerHeight,
  157. y: updatedY,
  158. });
  159. }
  160. let updatedY;
  161. if (textElement.verticalAlign === VERTICAL_ALIGN.TOP) {
  162. updatedY = element.y + BOUND_TEXT_PADDING;
  163. } else if (textElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
  164. updatedY = element.y + element.height - nextHeight - BOUND_TEXT_PADDING;
  165. } else {
  166. updatedY = element.y + element.height / 2 - nextHeight / 2;
  167. }
  168. const updatedX =
  169. textElement.textAlign === TEXT_ALIGN.LEFT
  170. ? element.x + BOUND_TEXT_PADDING
  171. : textElement.textAlign === TEXT_ALIGN.RIGHT
  172. ? element.x + element.width - nextWidth - BOUND_TEXT_PADDING
  173. : element.x + element.width / 2 - nextWidth / 2;
  174. mutateElement(textElement, {
  175. text,
  176. width: nextWidth,
  177. height: nextHeight,
  178. x: updatedX,
  179. y: updatedY,
  180. baseline: nextBaseLine,
  181. });
  182. }
  183. }
  184. };
  185. // https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
  186. export const measureText = (
  187. text: string,
  188. font: FontString,
  189. maxWidth?: number | null,
  190. ) => {
  191. text = text
  192. .split("\n")
  193. // replace empty lines with single space because leading/trailing empty
  194. // lines would be stripped from computation
  195. .map((x) => x || " ")
  196. .join("\n");
  197. const container = document.createElement("div");
  198. container.style.position = "absolute";
  199. container.style.whiteSpace = "pre";
  200. container.style.font = font;
  201. container.style.minHeight = "1em";
  202. if (maxWidth) {
  203. const lineHeight = getApproxLineHeight(font);
  204. container.style.maxWidth = `${String(maxWidth)}px`;
  205. container.style.overflow = "hidden";
  206. container.style.wordBreak = "break-word";
  207. container.style.lineHeight = `${String(lineHeight)}px`;
  208. container.style.whiteSpace = "pre-wrap";
  209. }
  210. document.body.appendChild(container);
  211. container.innerText = text;
  212. const span = document.createElement("span");
  213. span.style.display = "inline-block";
  214. span.style.overflow = "hidden";
  215. span.style.width = "1px";
  216. span.style.height = "1px";
  217. container.appendChild(span);
  218. // Baseline is important for positioning text on canvas
  219. const baseline = span.offsetTop + span.offsetHeight;
  220. // Since span adds 1px extra width to the container
  221. const width = container.offsetWidth + 1;
  222. const height = container.offsetHeight;
  223. document.body.removeChild(container);
  224. return { width, height, baseline };
  225. };
  226. const DUMMY_TEXT = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".toLocaleUpperCase();
  227. const cacheApproxLineHeight: { [key: FontString]: number } = {};
  228. export const getApproxLineHeight = (font: FontString) => {
  229. if (cacheApproxLineHeight[font]) {
  230. return cacheApproxLineHeight[font];
  231. }
  232. cacheApproxLineHeight[font] = measureText(DUMMY_TEXT, font, null).height;
  233. return cacheApproxLineHeight[font];
  234. };
  235. let canvas: HTMLCanvasElement | undefined;
  236. const getTextWidth = (text: string, font: FontString) => {
  237. if (!canvas) {
  238. canvas = document.createElement("canvas");
  239. }
  240. const canvas2dContext = canvas.getContext("2d")!;
  241. canvas2dContext.font = font;
  242. const metrics = canvas2dContext.measureText(text);
  243. // since in test env the canvas measureText algo
  244. // doesn't measure text and instead just returns number of
  245. // characters hence we assume that each letteris 10px
  246. if (isTestEnv()) {
  247. return metrics.width * 10;
  248. }
  249. return metrics.width;
  250. };
  251. export const wrapText = (text: string, font: FontString, maxWidth: number) => {
  252. const lines: Array<string> = [];
  253. const originalLines = text.split("\n");
  254. const spaceWidth = getTextWidth(" ", font);
  255. originalLines.forEach((originalLine) => {
  256. const words = originalLine.split(" ");
  257. // This means its newline so push it
  258. if (words.length === 1 && words[0] === "") {
  259. lines.push(words[0]);
  260. } else {
  261. let currentLine = "";
  262. let currentLineWidthTillNow = 0;
  263. let index = 0;
  264. while (index < words.length) {
  265. const currentWordWidth = getTextWidth(words[index], font);
  266. // Start breaking longer words exceeding max width
  267. if (currentWordWidth >= maxWidth) {
  268. // push current line since the current word exceeds the max width
  269. // so will be appended in next line
  270. if (currentLine) {
  271. lines.push(currentLine);
  272. }
  273. currentLine = "";
  274. currentLineWidthTillNow = 0;
  275. while (words[index].length > 0) {
  276. const currentChar = words[index][0];
  277. const width = charWidth.calculate(currentChar, font);
  278. currentLineWidthTillNow += width;
  279. words[index] = words[index].slice(1);
  280. if (currentLineWidthTillNow >= maxWidth) {
  281. // only remove last trailing space which we have added when joining words
  282. if (currentLine.slice(-1) === " ") {
  283. currentLine = currentLine.slice(0, -1);
  284. }
  285. lines.push(currentLine);
  286. currentLine = currentChar;
  287. currentLineWidthTillNow = width;
  288. if (currentLineWidthTillNow === maxWidth) {
  289. currentLine = "";
  290. currentLineWidthTillNow = 0;
  291. }
  292. } else {
  293. currentLine += currentChar;
  294. }
  295. }
  296. // push current line if appending space exceeds max width
  297. if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
  298. lines.push(currentLine);
  299. currentLine = "";
  300. currentLineWidthTillNow = 0;
  301. } else {
  302. // space needs to be appended before next word
  303. // as currentLine contains chars which couldn't be appended
  304. // to previous line
  305. currentLine += " ";
  306. currentLineWidthTillNow += spaceWidth;
  307. }
  308. index++;
  309. } else {
  310. // Start appending words in a line till max width reached
  311. while (currentLineWidthTillNow < maxWidth && index < words.length) {
  312. const word = words[index];
  313. currentLineWidthTillNow = getTextWidth(currentLine + word, font);
  314. if (currentLineWidthTillNow >= maxWidth) {
  315. lines.push(currentLine);
  316. currentLineWidthTillNow = 0;
  317. currentLine = "";
  318. break;
  319. }
  320. index++;
  321. currentLine += `${word} `;
  322. // Push the word if appending space exceeds max width
  323. if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
  324. lines.push(currentLine.slice(0, -1));
  325. currentLine = "";
  326. currentLineWidthTillNow = 0;
  327. break;
  328. }
  329. }
  330. if (currentLineWidthTillNow === maxWidth) {
  331. currentLine = "";
  332. currentLineWidthTillNow = 0;
  333. }
  334. }
  335. }
  336. if (currentLine) {
  337. // only remove last trailing space which we have added when joining words
  338. if (currentLine.slice(-1) === " ") {
  339. currentLine = currentLine.slice(0, -1);
  340. }
  341. lines.push(currentLine);
  342. }
  343. }
  344. });
  345. return lines.join("\n");
  346. };
  347. export const charWidth = (() => {
  348. const cachedCharWidth: { [key: FontString]: Array<number> } = {};
  349. const calculate = (char: string, font: FontString) => {
  350. const ascii = char.charCodeAt(0);
  351. if (!cachedCharWidth[font]) {
  352. cachedCharWidth[font] = [];
  353. }
  354. if (!cachedCharWidth[font][ascii]) {
  355. const width = getTextWidth(char, font);
  356. cachedCharWidth[font][ascii] = width;
  357. }
  358. return cachedCharWidth[font][ascii];
  359. };
  360. const getCache = (font: FontString) => {
  361. return cachedCharWidth[font];
  362. };
  363. return {
  364. calculate,
  365. getCache,
  366. };
  367. })();
  368. export const getApproxMinLineWidth = (font: FontString) => {
  369. const maxCharWidth = getMaxCharWidth(font);
  370. if (maxCharWidth === 0) {
  371. return (
  372. measureText(DUMMY_TEXT.split("").join("\n"), font).width +
  373. BOUND_TEXT_PADDING * 2
  374. );
  375. }
  376. return maxCharWidth + BOUND_TEXT_PADDING * 2;
  377. };
  378. export const getApproxMinLineHeight = (font: FontString) => {
  379. return getApproxLineHeight(font) + BOUND_TEXT_PADDING * 2;
  380. };
  381. export const getMinCharWidth = (font: FontString) => {
  382. const cache = charWidth.getCache(font);
  383. if (!cache) {
  384. return 0;
  385. }
  386. const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
  387. return Math.min(...cacheWithOutEmpty);
  388. };
  389. export const getMaxCharWidth = (font: FontString) => {
  390. const cache = charWidth.getCache(font);
  391. if (!cache) {
  392. return 0;
  393. }
  394. const cacheWithOutEmpty = cache.filter((val) => val !== undefined);
  395. return Math.max(...cacheWithOutEmpty);
  396. };
  397. export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
  398. // Generally lower case is used so converting to lower case
  399. const dummyText = DUMMY_TEXT.toLocaleLowerCase();
  400. const batchLength = 6;
  401. let index = 0;
  402. let widthTillNow = 0;
  403. let str = "";
  404. while (widthTillNow <= width) {
  405. const batch = dummyText.substr(index, index + batchLength);
  406. str += batch;
  407. widthTillNow += getTextWidth(str, font);
  408. if (index === dummyText.length - 1) {
  409. index = 0;
  410. }
  411. index = index + batchLength;
  412. }
  413. while (widthTillNow > width) {
  414. str = str.substr(0, str.length - 1);
  415. widthTillNow = getTextWidth(str, font);
  416. }
  417. return str.length;
  418. };
  419. export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
  420. return container?.boundElements?.length
  421. ? container?.boundElements?.filter((ele) => ele.type === "text")[0]?.id ||
  422. null
  423. : null;
  424. };
  425. export const getBoundTextElement = (element: ExcalidrawElement | null) => {
  426. if (!element) {
  427. return null;
  428. }
  429. const boundTextElementId = getBoundTextElementId(element);
  430. if (boundTextElementId) {
  431. return (
  432. (Scene.getScene(element)?.getElement(
  433. boundTextElementId,
  434. ) as ExcalidrawTextElementWithContainer) || null
  435. );
  436. }
  437. return null;
  438. };
  439. export const getContainerElement = (
  440. element:
  441. | (ExcalidrawElement & { containerId: ExcalidrawElement["id"] | null })
  442. | null,
  443. ) => {
  444. if (!element) {
  445. return null;
  446. }
  447. if (element.containerId) {
  448. return Scene.getScene(element)?.getElement(element.containerId) || null;
  449. }
  450. return null;
  451. };
  452. export const getContainerDims = (element: ExcalidrawElement) => {
  453. return { width: element.width, height: element.height };
  454. };
  455. export const isValidTextContainer = (element: ExcalidrawElement) => {
  456. return (
  457. element.type === "rectangle" ||
  458. element.type === "ellipse" ||
  459. element.type === "diamond" ||
  460. isImageElement(element)
  461. );
  462. };