textElement.ts 15 KB

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