textElement.ts 13 KB

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