textWysiwyg.test.tsx 36 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169
  1. import ReactDOM from "react-dom";
  2. import ExcalidrawApp from "../excalidraw-app";
  3. import { GlobalTestState, render, screen } from "../tests/test-utils";
  4. import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
  5. import { CODES, KEYS } from "../keys";
  6. import { fireEvent } from "../tests/test-utils";
  7. import { queryByText } from "@testing-library/react";
  8. import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
  9. import {
  10. ExcalidrawTextElement,
  11. ExcalidrawTextElementWithContainer,
  12. } from "./types";
  13. import * as textElementUtils from "./textElement";
  14. import { API } from "../tests/helpers/api";
  15. import { mutateElement } from "./mutateElement";
  16. import { resize } from "../tests/utils";
  17. import { getOriginalContainerHeightFromCache } from "./textWysiwyg";
  18. // Unmount ReactDOM from root
  19. ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
  20. const tab = " ";
  21. const mouse = new Pointer("mouse");
  22. describe("textWysiwyg", () => {
  23. describe("start text editing", () => {
  24. const { h } = window;
  25. beforeEach(async () => {
  26. await render(<ExcalidrawApp />);
  27. h.elements = [];
  28. });
  29. it("should prefer editing selected text element (non-bindable container present)", async () => {
  30. const line = API.createElement({
  31. type: "line",
  32. width: 100,
  33. height: 0,
  34. points: [
  35. [0, 0],
  36. [100, 0],
  37. ],
  38. });
  39. const textSize = 20;
  40. const text = API.createElement({
  41. type: "text",
  42. text: "ola",
  43. x: line.width / 2 - textSize / 2,
  44. y: -textSize / 2,
  45. width: textSize,
  46. height: textSize,
  47. });
  48. h.elements = [text, line];
  49. API.setSelectedElements([text]);
  50. Keyboard.keyPress(KEYS.ENTER);
  51. expect(h.state.editingElement?.id).toBe(text.id);
  52. expect(
  53. (h.state.editingElement as ExcalidrawTextElement).containerId,
  54. ).toBe(null);
  55. });
  56. it("should prefer editing selected text element (bindable container present)", async () => {
  57. const container = API.createElement({
  58. type: "rectangle",
  59. width: 100,
  60. boundElements: [],
  61. });
  62. const textSize = 20;
  63. const boundText = API.createElement({
  64. type: "text",
  65. text: "ola",
  66. x: container.width / 2 - textSize / 2,
  67. y: container.height / 2 - textSize / 2,
  68. width: textSize,
  69. height: textSize,
  70. containerId: container.id,
  71. });
  72. const boundText2 = API.createElement({
  73. type: "text",
  74. text: "ola",
  75. x: container.width / 2 - textSize / 2,
  76. y: container.height / 2 - textSize / 2,
  77. width: textSize,
  78. height: textSize,
  79. containerId: container.id,
  80. });
  81. h.elements = [container, boundText, boundText2];
  82. mutateElement(container, {
  83. boundElements: [{ type: "text", id: boundText.id }],
  84. });
  85. API.setSelectedElements([boundText2]);
  86. Keyboard.keyPress(KEYS.ENTER);
  87. expect(h.state.editingElement?.id).toBe(boundText2.id);
  88. });
  89. it("should not create bound text on ENTER if text exists at container center", () => {
  90. const container = API.createElement({
  91. type: "rectangle",
  92. width: 100,
  93. });
  94. const textSize = 20;
  95. const text = API.createElement({
  96. type: "text",
  97. text: "ola",
  98. x: container.width / 2 - textSize / 2,
  99. y: container.height / 2 - textSize / 2,
  100. width: textSize,
  101. height: textSize,
  102. containerId: container.id,
  103. });
  104. mutateElement(container, {
  105. boundElements: [{ type: "text", id: text.id }],
  106. });
  107. h.elements = [container, text];
  108. API.setSelectedElements([container]);
  109. Keyboard.keyPress(KEYS.ENTER);
  110. expect(h.state.editingElement?.id).toBe(text.id);
  111. });
  112. it("should edit existing bound text on ENTER even if higher z-index unbound text exists at container center", () => {
  113. const container = API.createElement({
  114. type: "rectangle",
  115. width: 100,
  116. boundElements: [],
  117. });
  118. const textSize = 20;
  119. const boundText = API.createElement({
  120. type: "text",
  121. text: "ola",
  122. x: container.width / 2 - textSize / 2,
  123. y: container.height / 2 - textSize / 2,
  124. width: textSize,
  125. height: textSize,
  126. containerId: container.id,
  127. });
  128. const boundText2 = API.createElement({
  129. type: "text",
  130. text: "ola",
  131. x: container.width / 2 - textSize / 2,
  132. y: container.height / 2 - textSize / 2,
  133. width: textSize,
  134. height: textSize,
  135. containerId: container.id,
  136. });
  137. h.elements = [container, boundText, boundText2];
  138. mutateElement(container, {
  139. boundElements: [{ type: "text", id: boundText.id }],
  140. });
  141. API.setSelectedElements([container]);
  142. Keyboard.keyPress(KEYS.ENTER);
  143. expect(h.state.editingElement?.id).toBe(boundText.id);
  144. });
  145. it("should edit text under cursor when clicked with text tool", () => {
  146. const text = API.createElement({
  147. type: "text",
  148. text: "ola",
  149. x: 60,
  150. y: 0,
  151. width: 100,
  152. height: 100,
  153. });
  154. h.elements = [text];
  155. UI.clickTool("text");
  156. mouse.clickAt(text.x + 50, text.y + 50);
  157. const editor = document.querySelector(
  158. ".excalidraw-textEditorContainer > textarea",
  159. ) as HTMLTextAreaElement;
  160. expect(editor).not.toBe(null);
  161. expect(h.state.editingElement?.id).toBe(text.id);
  162. expect(h.elements.length).toBe(1);
  163. });
  164. it("should edit text under cursor when double-clicked with selection tool", () => {
  165. const text = API.createElement({
  166. type: "text",
  167. text: "ola",
  168. x: 60,
  169. y: 0,
  170. width: 100,
  171. height: 100,
  172. });
  173. h.elements = [text];
  174. UI.clickTool("selection");
  175. mouse.doubleClickAt(text.x + 50, text.y + 50);
  176. const editor = document.querySelector(
  177. ".excalidraw-textEditorContainer > textarea",
  178. ) as HTMLTextAreaElement;
  179. expect(editor).not.toBe(null);
  180. expect(h.state.editingElement?.id).toBe(text.id);
  181. expect(h.elements.length).toBe(1);
  182. });
  183. });
  184. describe("Test container-unbound text", () => {
  185. const { h } = window;
  186. let textarea: HTMLTextAreaElement;
  187. let textElement: ExcalidrawTextElement;
  188. beforeEach(async () => {
  189. await render(<ExcalidrawApp />);
  190. textElement = UI.createElement("text");
  191. mouse.clickOn(textElement);
  192. textarea = document.querySelector(
  193. ".excalidraw-textEditorContainer > textarea",
  194. )!;
  195. });
  196. it("should add a tab at the start of the first line", () => {
  197. const event = new KeyboardEvent("keydown", { key: KEYS.TAB });
  198. textarea.value = "Line#1\nLine#2";
  199. // cursor: "|Line#1\nLine#2"
  200. textarea.selectionStart = 0;
  201. textarea.selectionEnd = 0;
  202. textarea.dispatchEvent(event);
  203. expect(textarea.value).toEqual(`${tab}Line#1\nLine#2`);
  204. // cursor: " |Line#1\nLine#2"
  205. expect(textarea.selectionStart).toEqual(4);
  206. expect(textarea.selectionEnd).toEqual(4);
  207. });
  208. it("should add a tab at the start of the second line", () => {
  209. const event = new KeyboardEvent("keydown", { key: KEYS.TAB });
  210. textarea.value = "Line#1\nLine#2";
  211. // cursor: "Line#1\nLin|e#2"
  212. textarea.selectionStart = 10;
  213. textarea.selectionEnd = 10;
  214. textarea.dispatchEvent(event);
  215. expect(textarea.value).toEqual(`Line#1\n${tab}Line#2`);
  216. // cursor: "Line#1\n Lin|e#2"
  217. expect(textarea.selectionStart).toEqual(14);
  218. expect(textarea.selectionEnd).toEqual(14);
  219. });
  220. it("should add a tab at the start of the first and second line", () => {
  221. const event = new KeyboardEvent("keydown", { key: KEYS.TAB });
  222. textarea.value = "Line#1\nLine#2\nLine#3";
  223. // cursor: "Li|ne#1\nLi|ne#2\nLine#3"
  224. textarea.selectionStart = 2;
  225. textarea.selectionEnd = 9;
  226. textarea.dispatchEvent(event);
  227. expect(textarea.value).toEqual(`${tab}Line#1\n${tab}Line#2\nLine#3`);
  228. // cursor: " Li|ne#1\n Li|ne#2\nLine#3"
  229. expect(textarea.selectionStart).toEqual(6);
  230. expect(textarea.selectionEnd).toEqual(17);
  231. });
  232. it("should remove a tab at the start of the first line", () => {
  233. const event = new KeyboardEvent("keydown", {
  234. key: KEYS.TAB,
  235. shiftKey: true,
  236. });
  237. textarea.value = `${tab}Line#1\nLine#2`;
  238. // cursor: "| Line#1\nLine#2"
  239. textarea.selectionStart = 0;
  240. textarea.selectionEnd = 0;
  241. textarea.dispatchEvent(event);
  242. expect(textarea.value).toEqual(`Line#1\nLine#2`);
  243. // cursor: "|Line#1\nLine#2"
  244. expect(textarea.selectionStart).toEqual(0);
  245. expect(textarea.selectionEnd).toEqual(0);
  246. });
  247. it("should remove a tab at the start of the second line", () => {
  248. const event = new KeyboardEvent("keydown", {
  249. key: KEYS.TAB,
  250. shiftKey: true,
  251. });
  252. // cursor: "Line#1\n Lin|e#2"
  253. textarea.value = `Line#1\n${tab}Line#2`;
  254. textarea.selectionStart = 15;
  255. textarea.selectionEnd = 15;
  256. textarea.dispatchEvent(event);
  257. expect(textarea.value).toEqual(`Line#1\nLine#2`);
  258. // cursor: "Line#1\nLin|e#2"
  259. expect(textarea.selectionStart).toEqual(11);
  260. expect(textarea.selectionEnd).toEqual(11);
  261. });
  262. it("should remove a tab at the start of the first and second line", () => {
  263. const event = new KeyboardEvent("keydown", {
  264. key: KEYS.TAB,
  265. shiftKey: true,
  266. });
  267. // cursor: " Li|ne#1\n Li|ne#2\nLine#3"
  268. textarea.value = `${tab}Line#1\n${tab}Line#2\nLine#3`;
  269. textarea.selectionStart = 6;
  270. textarea.selectionEnd = 17;
  271. textarea.dispatchEvent(event);
  272. expect(textarea.value).toEqual(`Line#1\nLine#2\nLine#3`);
  273. // cursor: "Li|ne#1\nLi|ne#2\nLine#3"
  274. expect(textarea.selectionStart).toEqual(2);
  275. expect(textarea.selectionEnd).toEqual(9);
  276. });
  277. it("should remove a tab at the start of the second line and cursor stay on this line", () => {
  278. const event = new KeyboardEvent("keydown", {
  279. key: KEYS.TAB,
  280. shiftKey: true,
  281. });
  282. // cursor: "Line#1\n | Line#2"
  283. textarea.value = `Line#1\n${tab}Line#2`;
  284. textarea.selectionStart = 9;
  285. textarea.selectionEnd = 9;
  286. textarea.dispatchEvent(event);
  287. // cursor: "Line#1\n|Line#2"
  288. expect(textarea.selectionStart).toEqual(7);
  289. // expect(textarea.selectionEnd).toEqual(7);
  290. });
  291. it("should remove partial tabs", () => {
  292. const event = new KeyboardEvent("keydown", {
  293. key: KEYS.TAB,
  294. shiftKey: true,
  295. });
  296. // cursor: "Line#1\n Line#|2"
  297. textarea.value = `Line#1\n Line#2`;
  298. textarea.selectionStart = 15;
  299. textarea.selectionEnd = 15;
  300. textarea.dispatchEvent(event);
  301. expect(textarea.value).toEqual(`Line#1\nLine#2`);
  302. });
  303. it("should remove nothing", () => {
  304. const event = new KeyboardEvent("keydown", {
  305. key: KEYS.TAB,
  306. shiftKey: true,
  307. });
  308. // cursor: "Line#1\n Li|ne#2"
  309. textarea.value = `Line#1\nLine#2`;
  310. textarea.selectionStart = 9;
  311. textarea.selectionEnd = 9;
  312. textarea.dispatchEvent(event);
  313. expect(textarea.value).toEqual(`Line#1\nLine#2`);
  314. });
  315. it("should resize text via shortcuts while in wysiwyg", () => {
  316. textarea.value = "abc def";
  317. const origFontSize = textElement.fontSize;
  318. textarea.dispatchEvent(
  319. new KeyboardEvent("keydown", {
  320. key: KEYS.CHEVRON_RIGHT,
  321. ctrlKey: true,
  322. shiftKey: true,
  323. }),
  324. );
  325. expect(textElement.fontSize).toBe(origFontSize * 1.1);
  326. textarea.dispatchEvent(
  327. new KeyboardEvent("keydown", {
  328. key: KEYS.CHEVRON_LEFT,
  329. ctrlKey: true,
  330. shiftKey: true,
  331. }),
  332. );
  333. expect(textElement.fontSize).toBe(origFontSize);
  334. });
  335. it("zooming via keyboard should zoom canvas", () => {
  336. expect(h.state.zoom.value).toBe(1);
  337. textarea.dispatchEvent(
  338. new KeyboardEvent("keydown", {
  339. code: CODES.MINUS,
  340. ctrlKey: true,
  341. }),
  342. );
  343. expect(h.state.zoom.value).toBe(0.9);
  344. textarea.dispatchEvent(
  345. new KeyboardEvent("keydown", {
  346. code: CODES.NUM_SUBTRACT,
  347. ctrlKey: true,
  348. }),
  349. );
  350. expect(h.state.zoom.value).toBe(0.8);
  351. textarea.dispatchEvent(
  352. new KeyboardEvent("keydown", {
  353. code: CODES.NUM_ADD,
  354. ctrlKey: true,
  355. }),
  356. );
  357. expect(h.state.zoom.value).toBe(0.9);
  358. textarea.dispatchEvent(
  359. new KeyboardEvent("keydown", {
  360. code: CODES.EQUAL,
  361. ctrlKey: true,
  362. }),
  363. );
  364. expect(h.state.zoom.value).toBe(1);
  365. });
  366. });
  367. describe("Test container-bound text", () => {
  368. let rectangle: any;
  369. const { h } = window;
  370. const DUMMY_HEIGHT = 240;
  371. const DUMMY_WIDTH = 160;
  372. const APPROX_LINE_HEIGHT = 25;
  373. const INITIAL_WIDTH = 10;
  374. beforeAll(() => {
  375. jest
  376. .spyOn(textElementUtils, "getApproxLineHeight")
  377. .mockReturnValue(APPROX_LINE_HEIGHT);
  378. });
  379. beforeEach(async () => {
  380. await render(<ExcalidrawApp />);
  381. h.elements = [];
  382. rectangle = UI.createElement("rectangle", {
  383. x: 10,
  384. y: 20,
  385. width: 90,
  386. height: 75,
  387. });
  388. });
  389. it("should bind text to container when double clicked on center of filled container", async () => {
  390. expect(h.elements.length).toBe(1);
  391. expect(h.elements[0].id).toBe(rectangle.id);
  392. mouse.doubleClickAt(
  393. rectangle.x + rectangle.width / 2,
  394. rectangle.y + rectangle.height / 2,
  395. );
  396. expect(h.elements.length).toBe(2);
  397. const text = h.elements[1] as ExcalidrawTextElementWithContainer;
  398. expect(text.type).toBe("text");
  399. expect(text.containerId).toBe(rectangle.id);
  400. expect(rectangle.boundElements).toStrictEqual([
  401. { id: text.id, type: "text" },
  402. ]);
  403. mouse.down();
  404. const editor = document.querySelector(
  405. ".excalidraw-textEditorContainer > textarea",
  406. ) as HTMLTextAreaElement;
  407. fireEvent.change(editor, { target: { value: "Hello World!" } });
  408. await new Promise((r) => setTimeout(r, 0));
  409. editor.blur();
  410. expect(rectangle.boundElements).toStrictEqual([
  411. { id: text.id, type: "text" },
  412. ]);
  413. });
  414. it("should bind text to container when double clicked on center of transparent container", async () => {
  415. const rectangle = API.createElement({
  416. type: "rectangle",
  417. x: 10,
  418. y: 20,
  419. width: 90,
  420. height: 75,
  421. backgroundColor: "transparent",
  422. });
  423. h.elements = [rectangle];
  424. mouse.doubleClickAt(
  425. rectangle.x + rectangle.width / 2,
  426. rectangle.y + rectangle.height / 2,
  427. );
  428. expect(h.elements.length).toBe(2);
  429. const text = h.elements[1] as ExcalidrawTextElementWithContainer;
  430. expect(text.type).toBe("text");
  431. expect(text.containerId).toBe(rectangle.id);
  432. mouse.down();
  433. const editor = document.querySelector(
  434. ".excalidraw-textEditorContainer > textarea",
  435. ) as HTMLTextAreaElement;
  436. fireEvent.change(editor, { target: { value: "Hello World!" } });
  437. await new Promise((r) => setTimeout(r, 0));
  438. editor.blur();
  439. expect(rectangle.boundElements).toStrictEqual([
  440. { id: text.id, type: "text" },
  441. ]);
  442. });
  443. it("should bind text to container when clicked on container and enter pressed", async () => {
  444. expect(h.elements.length).toBe(1);
  445. expect(h.elements[0].id).toBe(rectangle.id);
  446. Keyboard.keyPress(KEYS.ENTER);
  447. expect(h.elements.length).toBe(2);
  448. const text = h.elements[1] as ExcalidrawTextElementWithContainer;
  449. expect(text.type).toBe("text");
  450. expect(text.containerId).toBe(rectangle.id);
  451. const editor = document.querySelector(
  452. ".excalidraw-textEditorContainer > textarea",
  453. ) as HTMLTextAreaElement;
  454. await new Promise((r) => setTimeout(r, 0));
  455. fireEvent.change(editor, { target: { value: "Hello World!" } });
  456. editor.blur();
  457. expect(rectangle.boundElements).toStrictEqual([
  458. { id: text.id, type: "text" },
  459. ]);
  460. });
  461. it("shouldn't bind to non-text-bindable containers", async () => {
  462. const freedraw = API.createElement({
  463. type: "freedraw",
  464. width: 100,
  465. height: 0,
  466. });
  467. h.elements = [freedraw];
  468. UI.clickTool("text");
  469. mouse.clickAt(
  470. freedraw.x + freedraw.width / 2,
  471. freedraw.y + freedraw.height / 2,
  472. );
  473. const editor = document.querySelector(
  474. ".excalidraw-textEditorContainer > textarea",
  475. ) as HTMLTextAreaElement;
  476. fireEvent.change(editor, {
  477. target: {
  478. value: "Hello World!",
  479. },
  480. });
  481. fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
  482. editor.dispatchEvent(new Event("input"));
  483. expect(freedraw.boundElements).toBe(null);
  484. expect(h.elements[1].type).toBe("text");
  485. expect((h.elements[1] as ExcalidrawTextElement).containerId).toBe(null);
  486. });
  487. ["freedraw", "line"].forEach((type: any) => {
  488. it(`shouldn't create text element when pressing 'Enter' key on ${type} `, async () => {
  489. h.elements = [];
  490. const elemnet = UI.createElement(type, {
  491. width: 100,
  492. height: 50,
  493. });
  494. API.setSelectedElements([elemnet]);
  495. Keyboard.keyPress(KEYS.ENTER);
  496. expect(h.elements.length).toBe(1);
  497. });
  498. });
  499. it("should'nt bind text to container when not double clicked on center", async () => {
  500. expect(h.elements.length).toBe(1);
  501. expect(h.elements[0].id).toBe(rectangle.id);
  502. // clicking somewhere on top left
  503. mouse.doubleClickAt(rectangle.x + 20, rectangle.y + 20);
  504. expect(h.elements.length).toBe(2);
  505. const text = h.elements[1] as ExcalidrawTextElementWithContainer;
  506. expect(text.type).toBe("text");
  507. expect(text.containerId).toBe(null);
  508. mouse.down();
  509. const editor = document.querySelector(
  510. ".excalidraw-textEditorContainer > textarea",
  511. ) as HTMLTextAreaElement;
  512. fireEvent.change(editor, { target: { value: "Hello World!" } });
  513. await new Promise((r) => setTimeout(r, 0));
  514. editor.blur();
  515. expect(rectangle.boundElements).toBe(null);
  516. });
  517. it("should update font family correctly on undo/redo by selecting bounded text when font family was updated", async () => {
  518. expect(h.elements.length).toBe(1);
  519. mouse.doubleClickAt(
  520. rectangle.x + rectangle.width / 2,
  521. rectangle.y + rectangle.height / 2,
  522. );
  523. mouse.down();
  524. const text = h.elements[1] as ExcalidrawTextElementWithContainer;
  525. let editor = document.querySelector(
  526. ".excalidraw-textEditorContainer > textarea",
  527. ) as HTMLTextAreaElement;
  528. await new Promise((r) => setTimeout(r, 0));
  529. fireEvent.change(editor, { target: { value: "Hello World!" } });
  530. editor.blur();
  531. expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil);
  532. UI.clickTool("text");
  533. mouse.clickAt(
  534. rectangle.x + rectangle.width / 2,
  535. rectangle.y + rectangle.height / 2,
  536. );
  537. mouse.down();
  538. editor = document.querySelector(
  539. ".excalidraw-textEditorContainer > textarea",
  540. ) as HTMLTextAreaElement;
  541. editor.select();
  542. fireEvent.click(screen.getByTitle(/code/i));
  543. await new Promise((r) => setTimeout(r, 0));
  544. editor.blur();
  545. expect(
  546. (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
  547. ).toEqual(FONT_FAMILY.Cascadia);
  548. //undo
  549. Keyboard.withModifierKeys({ ctrl: true }, () => {
  550. Keyboard.keyPress(KEYS.Z);
  551. });
  552. expect(
  553. (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
  554. ).toEqual(FONT_FAMILY.Virgil);
  555. //redo
  556. Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
  557. Keyboard.keyPress(KEYS.Z);
  558. });
  559. expect(
  560. (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
  561. ).toEqual(FONT_FAMILY.Cascadia);
  562. });
  563. it("should wrap text and vertcially center align once text submitted", async () => {
  564. jest
  565. .spyOn(textElementUtils, "measureText")
  566. .mockImplementation((text, font, maxWidth) => {
  567. let width = INITIAL_WIDTH;
  568. let height = APPROX_LINE_HEIGHT;
  569. let baseline = 10;
  570. if (!text) {
  571. return {
  572. width,
  573. height,
  574. baseline,
  575. };
  576. }
  577. baseline = 30;
  578. width = DUMMY_WIDTH;
  579. if (text === "Hello \nWorld!") {
  580. height = APPROX_LINE_HEIGHT * 2;
  581. }
  582. if (maxWidth) {
  583. width = maxWidth;
  584. // To capture cases where maxWidth passed is initial width
  585. // due to which the text is not wrapped correctly
  586. if (maxWidth === INITIAL_WIDTH) {
  587. height = DUMMY_HEIGHT;
  588. }
  589. }
  590. return {
  591. width,
  592. height,
  593. baseline,
  594. };
  595. });
  596. expect(h.elements.length).toBe(1);
  597. Keyboard.keyDown(KEYS.ENTER);
  598. let text = h.elements[1] as ExcalidrawTextElementWithContainer;
  599. let editor = document.querySelector(
  600. ".excalidraw-textEditorContainer > textarea",
  601. ) as HTMLTextAreaElement;
  602. // mock scroll height
  603. jest
  604. .spyOn(editor, "scrollHeight", "get")
  605. .mockImplementation(() => APPROX_LINE_HEIGHT * 2);
  606. fireEvent.change(editor, {
  607. target: {
  608. value: "Hello World!",
  609. },
  610. });
  611. editor.dispatchEvent(new Event("input"));
  612. await new Promise((cb) => setTimeout(cb, 0));
  613. editor.blur();
  614. text = h.elements[1] as ExcalidrawTextElementWithContainer;
  615. expect(text.text).toBe("Hello \nWorld!");
  616. expect(text.originalText).toBe("Hello World!");
  617. expect(text.y).toBe(
  618. rectangle.y + rectangle.height / 2 - (APPROX_LINE_HEIGHT * 2) / 2,
  619. );
  620. expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING);
  621. expect(text.height).toBe(APPROX_LINE_HEIGHT * 2);
  622. expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2);
  623. // Edit and text by removing second line and it should
  624. // still vertically align correctly
  625. mouse.select(rectangle);
  626. Keyboard.keyPress(KEYS.ENTER);
  627. editor = document.querySelector(
  628. ".excalidraw-textEditorContainer > textarea",
  629. ) as HTMLTextAreaElement;
  630. fireEvent.change(editor, {
  631. target: {
  632. value: "Hello",
  633. },
  634. });
  635. // mock scroll height
  636. jest
  637. .spyOn(editor, "scrollHeight", "get")
  638. .mockImplementation(() => APPROX_LINE_HEIGHT);
  639. editor.style.height = "25px";
  640. editor.dispatchEvent(new Event("input"));
  641. await new Promise((r) => setTimeout(r, 0));
  642. editor.blur();
  643. text = h.elements[1] as ExcalidrawTextElementWithContainer;
  644. expect(text.text).toBe("Hello");
  645. expect(text.originalText).toBe("Hello");
  646. expect(text.y).toBe(
  647. rectangle.y + rectangle.height / 2 - APPROX_LINE_HEIGHT / 2,
  648. );
  649. expect(text.x).toBe(rectangle.x + BOUND_TEXT_PADDING);
  650. expect(text.height).toBe(APPROX_LINE_HEIGHT);
  651. expect(text.width).toBe(rectangle.width - BOUND_TEXT_PADDING * 2);
  652. });
  653. it("should unbind bound text when unbind action from context menu is triggered", async () => {
  654. expect(h.elements.length).toBe(1);
  655. expect(h.elements[0].id).toBe(rectangle.id);
  656. Keyboard.keyPress(KEYS.ENTER);
  657. expect(h.elements.length).toBe(2);
  658. const text = h.elements[1] as ExcalidrawTextElementWithContainer;
  659. expect(text.containerId).toBe(rectangle.id);
  660. const editor = document.querySelector(
  661. ".excalidraw-textEditorContainer > textarea",
  662. ) as HTMLTextAreaElement;
  663. await new Promise((r) => setTimeout(r, 0));
  664. fireEvent.change(editor, { target: { value: "Hello World!" } });
  665. editor.blur();
  666. expect(rectangle.boundElements).toStrictEqual([
  667. { id: text.id, type: "text" },
  668. ]);
  669. mouse.reset();
  670. UI.clickTool("selection");
  671. mouse.clickAt(10, 20);
  672. mouse.down();
  673. mouse.up();
  674. fireEvent.contextMenu(GlobalTestState.canvas, {
  675. button: 2,
  676. clientX: 20,
  677. clientY: 30,
  678. });
  679. const contextMenu = document.querySelector(".context-menu");
  680. fireEvent.click(queryByText(contextMenu as HTMLElement, "Unbind text")!);
  681. expect(h.elements[0].boundElements).toEqual([]);
  682. expect((h.elements[1] as ExcalidrawTextElement).containerId).toEqual(
  683. null,
  684. );
  685. });
  686. it("shouldn't bind to container if container has bound text", async () => {
  687. expect(h.elements.length).toBe(1);
  688. Keyboard.keyPress(KEYS.ENTER);
  689. expect(h.elements.length).toBe(2);
  690. // Bind first text
  691. const text = h.elements[1] as ExcalidrawTextElementWithContainer;
  692. expect(text.containerId).toBe(rectangle.id);
  693. const editor = document.querySelector(
  694. ".excalidraw-textEditorContainer > textarea",
  695. ) as HTMLTextAreaElement;
  696. await new Promise((r) => setTimeout(r, 0));
  697. fireEvent.change(editor, { target: { value: "Hello World!" } });
  698. editor.blur();
  699. expect(rectangle.boundElements).toStrictEqual([
  700. { id: text.id, type: "text" },
  701. ]);
  702. mouse.select(rectangle);
  703. Keyboard.keyPress(KEYS.ENTER);
  704. expect(h.elements.length).toBe(2);
  705. expect(rectangle.boundElements).toStrictEqual([
  706. { id: h.elements[1].id, type: "text" },
  707. ]);
  708. expect(text.containerId).toBe(rectangle.id);
  709. });
  710. it("should respect text alignment when resizing", async () => {
  711. Keyboard.keyPress(KEYS.ENTER);
  712. let editor = document.querySelector(
  713. ".excalidraw-textEditorContainer > textarea",
  714. ) as HTMLTextAreaElement;
  715. await new Promise((r) => setTimeout(r, 0));
  716. fireEvent.change(editor, { target: { value: "Hello" } });
  717. editor.blur();
  718. // should center align horizontally and vertically by default
  719. resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
  720. expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
  721. Array [
  722. 110,
  723. 17,
  724. ]
  725. `);
  726. mouse.select(rectangle);
  727. Keyboard.keyPress(KEYS.ENTER);
  728. editor = document.querySelector(
  729. ".excalidraw-textEditorContainer > textarea",
  730. ) as HTMLTextAreaElement;
  731. editor.select();
  732. fireEvent.click(screen.getByTitle("Left"));
  733. fireEvent.click(screen.getByTitle("Align bottom"));
  734. await new Promise((r) => setTimeout(r, 0));
  735. editor.blur();
  736. // should left align horizontally and bottom vertically after resize
  737. resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
  738. expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
  739. Array [
  740. 15,
  741. 90,
  742. ]
  743. `);
  744. mouse.select(rectangle);
  745. Keyboard.keyPress(KEYS.ENTER);
  746. editor = document.querySelector(
  747. ".excalidraw-textEditorContainer > textarea",
  748. ) as HTMLTextAreaElement;
  749. editor.select();
  750. fireEvent.click(screen.getByTitle("Right"));
  751. fireEvent.click(screen.getByTitle("Align top"));
  752. await new Promise((r) => setTimeout(r, 0));
  753. editor.blur();
  754. // should right align horizontally and top vertically after resize
  755. resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
  756. expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
  757. Array [
  758. 425,
  759. -539,
  760. ]
  761. `);
  762. });
  763. it("should always bind to selected container and insert it in correct position", async () => {
  764. const rectangle2 = UI.createElement("rectangle", {
  765. x: 5,
  766. y: 10,
  767. width: 120,
  768. height: 100,
  769. });
  770. API.setSelectedElements([rectangle]);
  771. Keyboard.keyPress(KEYS.ENTER);
  772. expect(h.elements.length).toBe(3);
  773. expect(h.elements[1].type).toBe("text");
  774. const text = h.elements[1] as ExcalidrawTextElementWithContainer;
  775. expect(text.type).toBe("text");
  776. expect(text.containerId).toBe(rectangle.id);
  777. mouse.down();
  778. const editor = document.querySelector(
  779. ".excalidraw-textEditorContainer > textarea",
  780. ) as HTMLTextAreaElement;
  781. fireEvent.change(editor, { target: { value: "Hello World!" } });
  782. await new Promise((r) => setTimeout(r, 0));
  783. editor.blur();
  784. expect(rectangle2.boundElements).toBeNull();
  785. expect(rectangle.boundElements).toStrictEqual([
  786. { id: text.id, type: "text" },
  787. ]);
  788. });
  789. it("should scale font size correctly when resizing using shift", async () => {
  790. Keyboard.keyPress(KEYS.ENTER);
  791. const editor = document.querySelector(
  792. ".excalidraw-textEditorContainer > textarea",
  793. ) as HTMLTextAreaElement;
  794. await new Promise((r) => setTimeout(r, 0));
  795. fireEvent.change(editor, { target: { value: "Hello" } });
  796. editor.blur();
  797. const textElement = h.elements[1] as ExcalidrawTextElement;
  798. expect(rectangle.width).toBe(90);
  799. expect(rectangle.height).toBe(75);
  800. expect(textElement.fontSize).toBe(20);
  801. resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 50], {
  802. shift: true,
  803. });
  804. expect(rectangle.width).toBe(200);
  805. expect(rectangle.height).toBe(166.66666666666669);
  806. expect(textElement.fontSize).toBe(47.5);
  807. });
  808. it("should bind text correctly when container duplicated with alt-drag", async () => {
  809. Keyboard.keyPress(KEYS.ENTER);
  810. const editor = document.querySelector(
  811. ".excalidraw-textEditorContainer > textarea",
  812. ) as HTMLTextAreaElement;
  813. await new Promise((r) => setTimeout(r, 0));
  814. fireEvent.change(editor, { target: { value: "Hello" } });
  815. editor.blur();
  816. expect(h.elements.length).toBe(2);
  817. mouse.select(rectangle);
  818. Keyboard.withModifierKeys({ alt: true }, () => {
  819. mouse.down(rectangle.x + 10, rectangle.y + 10);
  820. mouse.up(rectangle.x + 10, rectangle.y + 10);
  821. });
  822. expect(h.elements.length).toBe(4);
  823. const duplicatedRectangle = h.elements[0];
  824. const duplicatedText = h
  825. .elements[1] as ExcalidrawTextElementWithContainer;
  826. const originalRect = h.elements[2];
  827. const originalText = h.elements[3] as ExcalidrawTextElementWithContainer;
  828. expect(originalRect.boundElements).toStrictEqual([
  829. { id: originalText.id, type: "text" },
  830. ]);
  831. expect(originalText.containerId).toBe(originalRect.id);
  832. expect(duplicatedRectangle.boundElements).toStrictEqual([
  833. { id: duplicatedText.id, type: "text" },
  834. ]);
  835. expect(duplicatedText.containerId).toBe(duplicatedRectangle.id);
  836. });
  837. it("undo should work", async () => {
  838. Keyboard.keyPress(KEYS.ENTER);
  839. const editor = document.querySelector(
  840. ".excalidraw-textEditorContainer > textarea",
  841. ) as HTMLTextAreaElement;
  842. await new Promise((r) => setTimeout(r, 0));
  843. fireEvent.change(editor, { target: { value: "Hello" } });
  844. editor.blur();
  845. expect(rectangle.boundElements).toStrictEqual([
  846. { id: h.elements[1].id, type: "text" },
  847. ]);
  848. let text = h.elements[1] as ExcalidrawTextElementWithContainer;
  849. const originalRectX = rectangle.x;
  850. const originalRectY = rectangle.y;
  851. const originalTextX = text.x;
  852. const originalTextY = text.y;
  853. mouse.select(rectangle);
  854. mouse.downAt(rectangle.x, rectangle.y);
  855. mouse.moveTo(rectangle.x + 100, rectangle.y + 50);
  856. mouse.up(rectangle.x + 100, rectangle.y + 50);
  857. expect(rectangle.x).toBe(80);
  858. expect(rectangle.y).toBe(85);
  859. expect(text.x).toBe(90);
  860. expect(text.y).toBe(90);
  861. Keyboard.withModifierKeys({ ctrl: true }, () => {
  862. Keyboard.keyPress(KEYS.Z);
  863. });
  864. expect(rectangle.x).toBe(originalRectX);
  865. expect(rectangle.y).toBe(originalRectY);
  866. text = h.elements[1] as ExcalidrawTextElementWithContainer;
  867. expect(text.x).toBe(originalTextX);
  868. expect(text.y).toBe(originalTextY);
  869. expect(rectangle.boundElements).toStrictEqual([
  870. { id: text.id, type: "text" },
  871. ]);
  872. expect(text.containerId).toBe(rectangle.id);
  873. });
  874. it("should not allow bound text with only whitespaces", async () => {
  875. Keyboard.keyPress(KEYS.ENTER);
  876. const editor = document.querySelector(
  877. ".excalidraw-textEditorContainer > textarea",
  878. ) as HTMLTextAreaElement;
  879. await new Promise((r) => setTimeout(r, 0));
  880. fireEvent.change(editor, { target: { value: " " } });
  881. editor.blur();
  882. expect(rectangle.boundElements).toStrictEqual([]);
  883. expect(h.elements[1].isDeleted).toBe(true);
  884. });
  885. it("should restore original container height and clear cache once text is unbind", async () => {
  886. jest
  887. .spyOn(textElementUtils, "measureText")
  888. .mockImplementation((text, font, maxWidth) => {
  889. let width = INITIAL_WIDTH;
  890. let height = APPROX_LINE_HEIGHT;
  891. let baseline = 10;
  892. if (!text) {
  893. return {
  894. width,
  895. height,
  896. baseline,
  897. };
  898. }
  899. baseline = 30;
  900. width = DUMMY_WIDTH;
  901. height = APPROX_LINE_HEIGHT * 5;
  902. return {
  903. width,
  904. height,
  905. baseline,
  906. };
  907. });
  908. const originalRectHeight = rectangle.height;
  909. expect(rectangle.height).toBe(originalRectHeight);
  910. Keyboard.keyPress(KEYS.ENTER);
  911. const editor = document.querySelector(
  912. ".excalidraw-textEditorContainer > textarea",
  913. ) as HTMLTextAreaElement;
  914. await new Promise((r) => setTimeout(r, 0));
  915. fireEvent.change(editor, {
  916. target: { value: "Online whiteboard collaboration made easy" },
  917. });
  918. editor.blur();
  919. expect(rectangle.height).toBe(135);
  920. mouse.select(rectangle);
  921. fireEvent.contextMenu(GlobalTestState.canvas, {
  922. button: 2,
  923. clientX: 20,
  924. clientY: 30,
  925. });
  926. const contextMenu = document.querySelector(".context-menu");
  927. fireEvent.click(queryByText(contextMenu as HTMLElement, "Unbind text")!);
  928. expect(h.elements[0].boundElements).toEqual([]);
  929. expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
  930. expect(rectangle.height).toBe(originalRectHeight);
  931. });
  932. it("should reset the container height cache when resizing", async () => {
  933. Keyboard.keyPress(KEYS.ENTER);
  934. expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
  935. let editor = document.querySelector(
  936. ".excalidraw-textEditorContainer > textarea",
  937. ) as HTMLTextAreaElement;
  938. await new Promise((r) => setTimeout(r, 0));
  939. fireEvent.change(editor, { target: { value: "Hello" } });
  940. editor.blur();
  941. resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
  942. expect(rectangle.height).toBe(215);
  943. expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
  944. mouse.select(rectangle);
  945. Keyboard.keyPress(KEYS.ENTER);
  946. editor = document.querySelector(
  947. ".excalidraw-textEditorContainer > textarea",
  948. ) as HTMLTextAreaElement;
  949. await new Promise((r) => setTimeout(r, 0));
  950. editor.blur();
  951. expect(rectangle.height).toBe(215);
  952. // cache updated again
  953. expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(215);
  954. });
  955. //@todo fix this test later once measureText is mocked correctly
  956. it.skip("should reset the container height cache when font properties updated", async () => {
  957. Keyboard.keyPress(KEYS.ENTER);
  958. expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
  959. const editor = document.querySelector(
  960. ".excalidraw-textEditorContainer > textarea",
  961. ) as HTMLTextAreaElement;
  962. await new Promise((r) => setTimeout(r, 0));
  963. fireEvent.change(editor, { target: { value: "Hello World!" } });
  964. editor.blur();
  965. mouse.select(rectangle);
  966. Keyboard.keyPress(KEYS.ENTER);
  967. fireEvent.click(screen.getByTitle(/code/i));
  968. expect(
  969. (h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
  970. ).toEqual(FONT_FAMILY.Cascadia);
  971. expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
  972. fireEvent.click(screen.getByTitle(/Very large/i));
  973. expect(
  974. (h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
  975. ).toEqual(36);
  976. expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
  977. });
  978. });
  979. });