textWysiwyg.test.tsx 32 KB

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