textWysiwyg.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615
  1. import { CODES, KEYS } from "../keys";
  2. import {
  3. isWritableElement,
  4. getFontString,
  5. getFontFamilyString,
  6. isTestEnv,
  7. } from "../utils";
  8. import Scene from "../scene/Scene";
  9. import { isBoundToContainer, isTextElement } from "./typeChecks";
  10. import { CLASSES, BOUND_TEXT_PADDING } from "../constants";
  11. import {
  12. ExcalidrawBindableElement,
  13. ExcalidrawElement,
  14. ExcalidrawTextElement,
  15. } from "./types";
  16. import { AppState } from "../types";
  17. import { mutateElement } from "./mutateElement";
  18. import {
  19. getApproxLineHeight,
  20. getBoundTextElementId,
  21. wrapText,
  22. } from "./textElement";
  23. const normalizeText = (text: string) => {
  24. return (
  25. text
  26. // replace tabs with spaces so they render and measure correctly
  27. .replace(/\t/g, " ")
  28. // normalize newlines
  29. .replace(/\r?\n|\r/g, "\n")
  30. );
  31. };
  32. const getTransform = (
  33. width: number,
  34. height: number,
  35. angle: number,
  36. appState: AppState,
  37. maxWidth: number,
  38. maxHeight: number,
  39. ) => {
  40. const { zoom, offsetTop, offsetLeft } = appState;
  41. const degree = (180 * angle) / Math.PI;
  42. // offsets must be multiplied by 2 to account for the division by 2 of
  43. // the whole expression afterwards
  44. let translateX = ((width - offsetLeft * 2) * (zoom.value - 1)) / 2;
  45. let translateY = ((height - offsetTop * 2) * (zoom.value - 1)) / 2;
  46. if (width > maxWidth && zoom.value !== 1) {
  47. translateX = (maxWidth / 2) * (zoom.value - 1);
  48. }
  49. if (height > maxHeight && zoom.value !== 1) {
  50. translateY = ((maxHeight - offsetTop * 2) * (zoom.value - 1)) / 2;
  51. }
  52. return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
  53. };
  54. export const textWysiwyg = ({
  55. id,
  56. appState,
  57. onChange,
  58. onSubmit,
  59. getViewportCoords,
  60. element,
  61. canvas,
  62. excalidrawContainer,
  63. }: {
  64. id: ExcalidrawElement["id"];
  65. appState: AppState;
  66. onChange?: (text: string) => void;
  67. onSubmit: (data: {
  68. text: string;
  69. viaKeyboard: boolean;
  70. originalText: string;
  71. }) => void;
  72. getViewportCoords: (x: number, y: number) => [number, number];
  73. element: ExcalidrawTextElement;
  74. canvas: HTMLCanvasElement | null;
  75. excalidrawContainer: HTMLDivElement | null;
  76. }) => {
  77. const textPropertiesUpdated = (
  78. updatedElement: ExcalidrawTextElement,
  79. editable: HTMLTextAreaElement,
  80. ) => {
  81. const currentFont = editable.style.fontFamily.replaceAll('"', "");
  82. if (
  83. getFontFamilyString({ fontFamily: updatedElement.fontFamily }) !==
  84. currentFont
  85. ) {
  86. return true;
  87. }
  88. if (`${updatedElement.fontSize}px` !== editable.style.fontSize) {
  89. return true;
  90. }
  91. return false;
  92. };
  93. let originalContainerHeight: number;
  94. let approxLineHeight = getApproxLineHeight(getFontString(element));
  95. const initialText = element.originalText;
  96. const updateWysiwygStyle = () => {
  97. const updatedElement = Scene.getScene(element)?.getElement(id);
  98. if (updatedElement && isTextElement(updatedElement)) {
  99. let coordX = updatedElement.x;
  100. let coordY = updatedElement.y;
  101. const container = updatedElement?.containerId
  102. ? Scene.getScene(updatedElement)!.getElement(updatedElement.containerId)
  103. : null;
  104. let maxWidth = updatedElement.width;
  105. let maxHeight = updatedElement.height;
  106. let width = updatedElement.width;
  107. // Set to element height by default since thats
  108. // what is going to be used for unbounded text
  109. let height = updatedElement.height;
  110. if (container && updatedElement.containerId) {
  111. const propertiesUpdated = textPropertiesUpdated(
  112. updatedElement,
  113. editable,
  114. );
  115. // using editor.style.height to get the accurate height of text editor
  116. const editorHeight = Number(editable.style.height.slice(0, -2));
  117. if (editorHeight > 0) {
  118. height = editorHeight;
  119. }
  120. if (propertiesUpdated) {
  121. approxLineHeight = getApproxLineHeight(getFontString(updatedElement));
  122. originalContainerHeight = container.height;
  123. // update height of the editor after properties updated
  124. height = updatedElement.height;
  125. }
  126. if (!originalContainerHeight) {
  127. originalContainerHeight = container.height;
  128. }
  129. maxWidth = container.width - BOUND_TEXT_PADDING * 2;
  130. maxHeight = container.height - BOUND_TEXT_PADDING * 2;
  131. width = maxWidth;
  132. // The coordinates of text box set a distance of
  133. // 30px to preserve padding
  134. coordX = container.x + BOUND_TEXT_PADDING;
  135. // autogrow container height if text exceeds
  136. if (height > maxHeight) {
  137. const diff = Math.min(height - maxHeight, approxLineHeight);
  138. mutateElement(container, { height: container.height + diff });
  139. return;
  140. } else if (
  141. // autoshrink container height until original container height
  142. // is reached when text is removed
  143. container.height > originalContainerHeight &&
  144. height < maxHeight
  145. ) {
  146. const diff = Math.min(maxHeight - height, approxLineHeight);
  147. mutateElement(container, { height: container.height - diff });
  148. }
  149. // Start pushing text upward until a diff of 30px (padding)
  150. // is reached
  151. else {
  152. // vertically center align the text
  153. coordY = container.y + container.height / 2 - height / 2;
  154. }
  155. }
  156. const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
  157. const { textAlign } = updatedElement;
  158. editable.value = updatedElement.originalText;
  159. const lines = updatedElement.originalText.split("\n");
  160. const lineHeight = updatedElement.containerId
  161. ? approxLineHeight
  162. : updatedElement.height / lines.length;
  163. if (!container) {
  164. maxWidth =
  165. (appState.offsetLeft + appState.width - viewportX - 8) /
  166. appState.zoom.value -
  167. // margin-right of parent if any
  168. Number(
  169. getComputedStyle(
  170. excalidrawContainer?.parentNode as Element,
  171. ).marginRight.slice(0, -2),
  172. );
  173. }
  174. // Make sure text editor height doesn't go beyond viewport
  175. const editorMaxHeight =
  176. (appState.height -
  177. viewportY -
  178. // There is a ~14px difference which keeps on increasing
  179. // with every zoom step when offset present hence I am subtracting it here
  180. // However this is not the best fix and breaks in
  181. // few scenarios
  182. (appState.offsetTop
  183. ? ((appState.zoom.value * 100 - 100) / 10) * 14
  184. : 0)) /
  185. appState.zoom.value;
  186. const angle = container ? container.angle : updatedElement.angle;
  187. Object.assign(editable.style, {
  188. font: getFontString(updatedElement),
  189. // must be defined *after* font ¯\_(ツ)_/¯
  190. lineHeight: `${lineHeight}px`,
  191. width: `${width}px`,
  192. height: `${height}px`,
  193. left: `${viewportX}px`,
  194. top: `${viewportY}px`,
  195. transform: getTransform(
  196. width,
  197. height,
  198. angle,
  199. appState,
  200. maxWidth,
  201. editorMaxHeight,
  202. ),
  203. textAlign,
  204. color: updatedElement.strokeColor,
  205. opacity: updatedElement.opacity / 100,
  206. filter: "var(--theme-filter)",
  207. maxWidth: `${maxWidth}px`,
  208. maxHeight: `${editorMaxHeight}px`,
  209. });
  210. // For some reason updating font attribute doesn't set font family
  211. // hence updating font family explicitly for test environment
  212. if (isTestEnv()) {
  213. editable.style.fontFamily = getFontFamilyString(updatedElement);
  214. }
  215. }
  216. };
  217. const editable = document.createElement("textarea");
  218. editable.dir = "auto";
  219. editable.tabIndex = 0;
  220. editable.dataset.type = "wysiwyg";
  221. // prevent line wrapping on Safari
  222. editable.wrap = "off";
  223. editable.classList.add("excalidraw-wysiwyg");
  224. let whiteSpace = "pre";
  225. let wordBreak = "normal";
  226. if (isBoundToContainer(element)) {
  227. whiteSpace = "pre-wrap";
  228. wordBreak = "break-word";
  229. }
  230. Object.assign(editable.style, {
  231. position: "absolute",
  232. display: "inline-block",
  233. minHeight: "1em",
  234. backfaceVisibility: "hidden",
  235. margin: 0,
  236. padding: 0,
  237. border: 0,
  238. outline: 0,
  239. resize: "none",
  240. background: "transparent",
  241. overflow: "hidden",
  242. // must be specified because in dark mode canvas creates a stacking context
  243. zIndex: "var(--zIndex-wysiwyg)",
  244. wordBreak,
  245. // prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
  246. whiteSpace,
  247. overflowWrap: "break-word",
  248. });
  249. updateWysiwygStyle();
  250. if (onChange) {
  251. editable.oninput = () => {
  252. // using scrollHeight here since we need to calculate
  253. // number of lines so cannot use editable.style.height
  254. // as that gets updated below
  255. const lines = editable.scrollHeight / approxLineHeight;
  256. // auto increase height only when lines > 1 so its
  257. // measured correctly and vertically alignes for
  258. // first line as well as setting height to "auto"
  259. // doubles the height as soon as user starts typing
  260. if (isBoundToContainer(element) && lines > 1) {
  261. let height = "auto";
  262. if (lines === 2) {
  263. const container = Scene.getScene(element)!.getElement(
  264. element.containerId,
  265. );
  266. const actualLineCount = wrapText(
  267. editable.value,
  268. getFontString(element),
  269. container!.width,
  270. ).split("\n").length;
  271. // This is browser behaviour when setting height to "auto"
  272. // It sets the height needed for 2 lines even if actual
  273. // line count is 1 as mentioned above as well
  274. // hence reducing the height by half if actual line count is 1
  275. // so single line aligns vertically when deleting
  276. if (actualLineCount === 1) {
  277. height = `${editable.scrollHeight / 2}px`;
  278. }
  279. }
  280. editable.style.height = height;
  281. editable.style.height = `${editable.scrollHeight}px`;
  282. }
  283. onChange(normalizeText(editable.value));
  284. };
  285. }
  286. editable.onkeydown = (event) => {
  287. event.stopPropagation();
  288. if (event.key === KEYS.ESCAPE) {
  289. event.preventDefault();
  290. submittedViaKeyboard = true;
  291. handleSubmit();
  292. } else if (event.key === KEYS.ENTER && event[KEYS.CTRL_OR_CMD]) {
  293. event.preventDefault();
  294. if (event.isComposing || event.keyCode === 229) {
  295. return;
  296. }
  297. submittedViaKeyboard = true;
  298. handleSubmit();
  299. } else if (
  300. event.key === KEYS.TAB ||
  301. (event[KEYS.CTRL_OR_CMD] &&
  302. (event.code === CODES.BRACKET_LEFT ||
  303. event.code === CODES.BRACKET_RIGHT))
  304. ) {
  305. event.preventDefault();
  306. if (event.shiftKey || event.code === CODES.BRACKET_LEFT) {
  307. outdent();
  308. } else {
  309. indent();
  310. }
  311. // We must send an input event to resize the element
  312. editable.dispatchEvent(new Event("input"));
  313. }
  314. };
  315. const TAB_SIZE = 4;
  316. const TAB = " ".repeat(TAB_SIZE);
  317. const RE_LEADING_TAB = new RegExp(`^ {1,${TAB_SIZE}}`);
  318. const indent = () => {
  319. const { selectionStart, selectionEnd } = editable;
  320. const linesStartIndices = getSelectedLinesStartIndices();
  321. let value = editable.value;
  322. linesStartIndices.forEach((startIndex: number) => {
  323. const startValue = value.slice(0, startIndex);
  324. const endValue = value.slice(startIndex);
  325. value = `${startValue}${TAB}${endValue}`;
  326. });
  327. editable.value = value;
  328. editable.selectionStart = selectionStart + TAB_SIZE;
  329. editable.selectionEnd = selectionEnd + TAB_SIZE * linesStartIndices.length;
  330. };
  331. const outdent = () => {
  332. const { selectionStart, selectionEnd } = editable;
  333. const linesStartIndices = getSelectedLinesStartIndices();
  334. const removedTabs: number[] = [];
  335. let value = editable.value;
  336. linesStartIndices.forEach((startIndex) => {
  337. const tabMatch = value
  338. .slice(startIndex, startIndex + TAB_SIZE)
  339. .match(RE_LEADING_TAB);
  340. if (tabMatch) {
  341. const startValue = value.slice(0, startIndex);
  342. const endValue = value.slice(startIndex + tabMatch[0].length);
  343. // Delete a tab from the line
  344. value = `${startValue}${endValue}`;
  345. removedTabs.push(startIndex);
  346. }
  347. });
  348. editable.value = value;
  349. if (removedTabs.length) {
  350. if (selectionStart > removedTabs[removedTabs.length - 1]) {
  351. editable.selectionStart = Math.max(
  352. selectionStart - TAB_SIZE,
  353. removedTabs[removedTabs.length - 1],
  354. );
  355. } else {
  356. // If the cursor is before the first tab removed, ex:
  357. // Line| #1
  358. // Line #2
  359. // Lin|e #3
  360. // we should reset the selectionStart to his initial value.
  361. editable.selectionStart = selectionStart;
  362. }
  363. editable.selectionEnd = Math.max(
  364. editable.selectionStart,
  365. selectionEnd - TAB_SIZE * removedTabs.length,
  366. );
  367. }
  368. };
  369. /**
  370. * @returns indeces of start positions of selected lines, in reverse order
  371. */
  372. const getSelectedLinesStartIndices = () => {
  373. let { selectionStart, selectionEnd, value } = editable;
  374. // chars before selectionStart on the same line
  375. const startOffset = value.slice(0, selectionStart).match(/[^\n]*$/)![0]
  376. .length;
  377. // put caret at the start of the line
  378. selectionStart = selectionStart - startOffset;
  379. const selected = value.slice(selectionStart, selectionEnd);
  380. return selected
  381. .split("\n")
  382. .reduce(
  383. (startIndices, line, idx, lines) =>
  384. startIndices.concat(
  385. idx
  386. ? // curr line index is prev line's start + prev line's length + \n
  387. startIndices[idx - 1] + lines[idx - 1].length + 1
  388. : // first selected line
  389. selectionStart,
  390. ),
  391. [] as number[],
  392. )
  393. .reverse();
  394. };
  395. const stopEvent = (event: Event) => {
  396. event.preventDefault();
  397. event.stopPropagation();
  398. };
  399. // using a state variable instead of passing it to the handleSubmit callback
  400. // so that we don't need to create separate a callback for event handlers
  401. let submittedViaKeyboard = false;
  402. const handleSubmit = () => {
  403. // cleanup must be run before onSubmit otherwise when app blurs the wysiwyg
  404. // it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the
  405. // wysiwyg on update
  406. cleanup();
  407. const updateElement = Scene.getScene(element)?.getElement(element.id);
  408. if (!updateElement) {
  409. return;
  410. }
  411. let wrappedText = "";
  412. if (isTextElement(updateElement) && updateElement?.containerId) {
  413. const container = Scene.getScene(updateElement)!.getElement(
  414. updateElement.containerId,
  415. ) as ExcalidrawBindableElement;
  416. if (container) {
  417. wrappedText = wrapText(
  418. editable.value,
  419. getFontString(updateElement),
  420. container.width,
  421. );
  422. if (updateElement.containerId) {
  423. const editorHeight = Number(editable.style.height.slice(0, -2));
  424. if (editable.value) {
  425. // Don't mutate if text is not updated
  426. if (initialText !== editable.value) {
  427. mutateElement(updateElement, {
  428. // vertically center align
  429. y: container.y + container.height / 2 - editorHeight / 2,
  430. height: editorHeight,
  431. width: Number(editable.style.width.slice(0, -2)),
  432. // preserve padding
  433. x: container.x + BOUND_TEXT_PADDING,
  434. angle: container.angle,
  435. });
  436. }
  437. const boundTextElementId = getBoundTextElementId(container);
  438. if (!boundTextElementId || boundTextElementId !== element.id) {
  439. mutateElement(container, {
  440. boundElements: (container.boundElements || []).concat({
  441. type: "text",
  442. id: element.id,
  443. }),
  444. });
  445. }
  446. } else {
  447. mutateElement(container, {
  448. boundElements: container.boundElements?.filter(
  449. (ele) => ele.type !== "text",
  450. ),
  451. });
  452. }
  453. }
  454. }
  455. } else {
  456. wrappedText = editable.value;
  457. }
  458. onSubmit({
  459. text: normalizeText(wrappedText),
  460. viaKeyboard: submittedViaKeyboard,
  461. originalText: editable.value,
  462. });
  463. };
  464. const cleanup = () => {
  465. if (isDestroyed) {
  466. return;
  467. }
  468. isDestroyed = true;
  469. // remove events to ensure they don't late-fire
  470. editable.onblur = null;
  471. editable.oninput = null;
  472. editable.onkeydown = null;
  473. if (observer) {
  474. observer.disconnect();
  475. }
  476. window.removeEventListener("resize", updateWysiwygStyle);
  477. window.removeEventListener("wheel", stopEvent, true);
  478. window.removeEventListener("pointerdown", onPointerDown);
  479. window.removeEventListener("pointerup", bindBlurEvent);
  480. window.removeEventListener("blur", handleSubmit);
  481. unbindUpdate();
  482. editable.remove();
  483. };
  484. const bindBlurEvent = (event?: MouseEvent) => {
  485. window.removeEventListener("pointerup", bindBlurEvent);
  486. // Deferred so that the pointerdown that initiates the wysiwyg doesn't
  487. // trigger the blur on ensuing pointerup.
  488. // Also to handle cases such as picking a color which would trigger a blur
  489. // in that same tick.
  490. const target = event?.target;
  491. const isTargetColorPicker =
  492. target instanceof HTMLInputElement &&
  493. target.closest(".color-picker-input") &&
  494. isWritableElement(target);
  495. setTimeout(() => {
  496. editable.onblur = handleSubmit;
  497. if (target && isTargetColorPicker) {
  498. target.onblur = () => {
  499. editable.focus();
  500. };
  501. }
  502. // case: clicking on the same property → no change → no update → no focus
  503. if (!isTargetColorPicker) {
  504. editable.focus();
  505. }
  506. });
  507. };
  508. // prevent blur when changing properties from the menu
  509. const onPointerDown = (event: MouseEvent) => {
  510. const isTargetColorPicker =
  511. event.target instanceof HTMLInputElement &&
  512. event.target.closest(".color-picker-input") &&
  513. isWritableElement(event.target);
  514. if (
  515. ((event.target instanceof HTMLElement ||
  516. event.target instanceof SVGElement) &&
  517. event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
  518. !isWritableElement(event.target)) ||
  519. isTargetColorPicker
  520. ) {
  521. editable.onblur = null;
  522. window.addEventListener("pointerup", bindBlurEvent);
  523. // handle edge-case where pointerup doesn't fire e.g. due to user
  524. // alt-tabbing away
  525. window.addEventListener("blur", handleSubmit);
  526. }
  527. };
  528. // handle updates of textElement properties of editing element
  529. const unbindUpdate = Scene.getScene(element)!.addCallback(() => {
  530. updateWysiwygStyle();
  531. const isColorPickerActive = !!document.activeElement?.closest(
  532. ".color-picker-input",
  533. );
  534. if (!isColorPickerActive) {
  535. editable.focus();
  536. }
  537. });
  538. // ---------------------------------------------------------------------------
  539. let isDestroyed = false;
  540. // select on init (focusing is done separately inside the bindBlurEvent()
  541. // because we need it to happen *after* the blur event from `pointerdown`)
  542. editable.select();
  543. bindBlurEvent();
  544. // reposition wysiwyg in case of canvas is resized. Using ResizeObserver
  545. // is preferred so we catch changes from host, where window may not resize.
  546. let observer: ResizeObserver | null = null;
  547. if (canvas && "ResizeObserver" in window) {
  548. observer = new window.ResizeObserver(() => {
  549. updateWysiwygStyle();
  550. });
  551. observer.observe(canvas);
  552. } else {
  553. window.addEventListener("resize", updateWysiwygStyle);
  554. }
  555. window.addEventListener("pointerdown", onPointerDown);
  556. window.addEventListener("wheel", stopEvent, {
  557. passive: false,
  558. capture: true,
  559. });
  560. excalidrawContainer
  561. ?.querySelector(".excalidraw-textEditorContainer")!
  562. .appendChild(editable);
  563. };