textWysiwyg.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. import { CODES, KEYS } from "../keys";
  2. import { isWritableElement, getFontString } from "../utils";
  3. import Scene from "../scene/Scene";
  4. import { isTextElement } from "./typeChecks";
  5. import { CLASSES } from "../constants";
  6. import { ExcalidrawElement } from "./types";
  7. import { AppState } from "../types";
  8. const normalizeText = (text: string) => {
  9. return (
  10. text
  11. // replace tabs with spaces so they render and measure correctly
  12. .replace(/\t/g, " ")
  13. // normalize newlines
  14. .replace(/\r?\n|\r/g, "\n")
  15. );
  16. };
  17. const getTransform = (
  18. width: number,
  19. height: number,
  20. angle: number,
  21. appState: AppState,
  22. maxWidth: number,
  23. ) => {
  24. const { zoom, offsetTop, offsetLeft } = appState;
  25. const degree = (180 * angle) / Math.PI;
  26. // offsets must be multiplied by 2 to account for the division by 2 of
  27. // the whole expression afterwards
  28. let translateX = ((width - offsetLeft * 2) * (zoom.value - 1)) / 2;
  29. const translateY = ((height - offsetTop * 2) * (zoom.value - 1)) / 2;
  30. if (width > maxWidth && zoom.value !== 1) {
  31. translateX = (maxWidth / 2) * (zoom.value - 1);
  32. }
  33. return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
  34. };
  35. export const textWysiwyg = ({
  36. id,
  37. appState,
  38. onChange,
  39. onSubmit,
  40. getViewportCoords,
  41. element,
  42. canvas,
  43. excalidrawContainer,
  44. }: {
  45. id: ExcalidrawElement["id"];
  46. appState: AppState;
  47. onChange?: (text: string) => void;
  48. onSubmit: (data: { text: string; viaKeyboard: boolean }) => void;
  49. getViewportCoords: (x: number, y: number) => [number, number];
  50. element: ExcalidrawElement;
  51. canvas: HTMLCanvasElement | null;
  52. excalidrawContainer: HTMLDivElement | null;
  53. }) => {
  54. const updateWysiwygStyle = () => {
  55. const updatedElement = Scene.getScene(element)?.getElement(id);
  56. if (updatedElement && isTextElement(updatedElement)) {
  57. const [viewportX, viewportY] = getViewportCoords(
  58. updatedElement.x,
  59. updatedElement.y,
  60. );
  61. const { textAlign, angle } = updatedElement;
  62. editable.value = updatedElement.text;
  63. const lines = updatedElement.text.replace(/\r\n?/g, "\n").split("\n");
  64. const lineHeight = updatedElement.height / lines.length;
  65. const maxWidth =
  66. (appState.offsetLeft + appState.width - viewportX - 8) /
  67. appState.zoom.value -
  68. // margin-right of parent if any
  69. Number(
  70. getComputedStyle(
  71. excalidrawContainer?.parentNode as Element,
  72. ).marginRight.slice(0, -2),
  73. );
  74. Object.assign(editable.style, {
  75. font: getFontString(updatedElement),
  76. // must be defined *after* font ¯\_(ツ)_/¯
  77. lineHeight: `${lineHeight}px`,
  78. width: `${updatedElement.width}px`,
  79. height: `${updatedElement.height}px`,
  80. left: `${viewportX}px`,
  81. top: `${viewportY}px`,
  82. transform: getTransform(
  83. updatedElement.width,
  84. updatedElement.height,
  85. angle,
  86. appState,
  87. maxWidth,
  88. ),
  89. textAlign,
  90. color: updatedElement.strokeColor,
  91. opacity: updatedElement.opacity / 100,
  92. filter: "var(--theme-filter)",
  93. maxWidth: `${maxWidth}px`,
  94. });
  95. }
  96. };
  97. const editable = document.createElement("textarea");
  98. editable.dir = "auto";
  99. editable.tabIndex = 0;
  100. editable.dataset.type = "wysiwyg";
  101. // prevent line wrapping on Safari
  102. editable.wrap = "off";
  103. editable.classList.add("excalidraw-wysiwyg");
  104. Object.assign(editable.style, {
  105. position: "absolute",
  106. display: "inline-block",
  107. minHeight: "1em",
  108. backfaceVisibility: "hidden",
  109. margin: 0,
  110. padding: 0,
  111. border: 0,
  112. outline: 0,
  113. resize: "none",
  114. background: "transparent",
  115. overflow: "hidden",
  116. // prevent line wrapping (`whitespace: nowrap` doesn't work on FF)
  117. whiteSpace: "pre",
  118. // must be specified because in dark mode canvas creates a stacking context
  119. zIndex: "var(--zIndex-wysiwyg)",
  120. });
  121. updateWysiwygStyle();
  122. if (onChange) {
  123. editable.oninput = () => {
  124. onChange(normalizeText(editable.value));
  125. };
  126. }
  127. editable.onkeydown = (event) => {
  128. event.stopPropagation();
  129. if (event.key === KEYS.ESCAPE) {
  130. event.preventDefault();
  131. submittedViaKeyboard = true;
  132. handleSubmit();
  133. } else if (event.key === KEYS.ENTER && event[KEYS.CTRL_OR_CMD]) {
  134. event.preventDefault();
  135. if (event.isComposing || event.keyCode === 229) {
  136. return;
  137. }
  138. submittedViaKeyboard = true;
  139. handleSubmit();
  140. } else if (
  141. event.key === KEYS.TAB ||
  142. (event[KEYS.CTRL_OR_CMD] &&
  143. (event.code === CODES.BRACKET_LEFT ||
  144. event.code === CODES.BRACKET_RIGHT))
  145. ) {
  146. event.preventDefault();
  147. if (event.shiftKey || event.code === CODES.BRACKET_LEFT) {
  148. outdent();
  149. } else {
  150. indent();
  151. }
  152. // We must send an input event to resize the element
  153. editable.dispatchEvent(new Event("input"));
  154. }
  155. };
  156. const TAB_SIZE = 4;
  157. const TAB = " ".repeat(TAB_SIZE);
  158. const RE_LEADING_TAB = new RegExp(`^ {1,${TAB_SIZE}}`);
  159. const indent = () => {
  160. const { selectionStart, selectionEnd } = editable;
  161. const linesStartIndices = getSelectedLinesStartIndices();
  162. let value = editable.value;
  163. linesStartIndices.forEach((startIndex) => {
  164. const startValue = value.slice(0, startIndex);
  165. const endValue = value.slice(startIndex);
  166. value = `${startValue}${TAB}${endValue}`;
  167. });
  168. editable.value = value;
  169. editable.selectionStart = selectionStart + TAB_SIZE;
  170. editable.selectionEnd = selectionEnd + TAB_SIZE * linesStartIndices.length;
  171. };
  172. const outdent = () => {
  173. const { selectionStart, selectionEnd } = editable;
  174. const linesStartIndices = getSelectedLinesStartIndices();
  175. const removedTabs: number[] = [];
  176. let value = editable.value;
  177. linesStartIndices.forEach((startIndex) => {
  178. const tabMatch = value
  179. .slice(startIndex, startIndex + TAB_SIZE)
  180. .match(RE_LEADING_TAB);
  181. if (tabMatch) {
  182. const startValue = value.slice(0, startIndex);
  183. const endValue = value.slice(startIndex + tabMatch[0].length);
  184. // Delete a tab from the line
  185. value = `${startValue}${endValue}`;
  186. removedTabs.push(startIndex);
  187. }
  188. });
  189. editable.value = value;
  190. if (removedTabs.length) {
  191. if (selectionStart > removedTabs[removedTabs.length - 1]) {
  192. editable.selectionStart = Math.max(
  193. selectionStart - TAB_SIZE,
  194. removedTabs[removedTabs.length - 1],
  195. );
  196. } else {
  197. // If the cursor is before the first tab removed, ex:
  198. // Line| #1
  199. // Line #2
  200. // Lin|e #3
  201. // we should reset the selectionStart to his initial value.
  202. editable.selectionStart = selectionStart;
  203. }
  204. editable.selectionEnd = Math.max(
  205. editable.selectionStart,
  206. selectionEnd - TAB_SIZE * removedTabs.length,
  207. );
  208. }
  209. };
  210. /**
  211. * @returns indeces of start positions of selected lines, in reverse order
  212. */
  213. const getSelectedLinesStartIndices = () => {
  214. let { selectionStart, selectionEnd, value } = editable;
  215. // chars before selectionStart on the same line
  216. const startOffset = value.slice(0, selectionStart).match(/[^\n]*$/)![0]
  217. .length;
  218. // put caret at the start of the line
  219. selectionStart = selectionStart - startOffset;
  220. const selected = value.slice(selectionStart, selectionEnd);
  221. return selected
  222. .split("\n")
  223. .reduce(
  224. (startIndices, line, idx, lines) =>
  225. startIndices.concat(
  226. idx
  227. ? // curr line index is prev line's start + prev line's length + \n
  228. startIndices[idx - 1] + lines[idx - 1].length + 1
  229. : // first selected line
  230. selectionStart,
  231. ),
  232. [] as number[],
  233. )
  234. .reverse();
  235. };
  236. const stopEvent = (event: Event) => {
  237. event.preventDefault();
  238. event.stopPropagation();
  239. };
  240. // using a state variable instead of passing it to the handleSubmit callback
  241. // so that we don't need to create separate a callback for event handlers
  242. let submittedViaKeyboard = false;
  243. const handleSubmit = () => {
  244. // cleanup must be run before onSubmit otherwise when app blurs the wysiwyg
  245. // it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the
  246. // wysiwyg on update
  247. cleanup();
  248. onSubmit({
  249. text: normalizeText(editable.value),
  250. viaKeyboard: submittedViaKeyboard,
  251. });
  252. };
  253. const cleanup = () => {
  254. if (isDestroyed) {
  255. return;
  256. }
  257. isDestroyed = true;
  258. // remove events to ensure they don't late-fire
  259. editable.onblur = null;
  260. editable.oninput = null;
  261. editable.onkeydown = null;
  262. if (observer) {
  263. observer.disconnect();
  264. }
  265. window.removeEventListener("resize", updateWysiwygStyle);
  266. window.removeEventListener("wheel", stopEvent, true);
  267. window.removeEventListener("pointerdown", onPointerDown);
  268. window.removeEventListener("pointerup", bindBlurEvent);
  269. window.removeEventListener("blur", handleSubmit);
  270. unbindUpdate();
  271. editable.remove();
  272. };
  273. const bindBlurEvent = () => {
  274. window.removeEventListener("pointerup", bindBlurEvent);
  275. // Deferred so that the pointerdown that initiates the wysiwyg doesn't
  276. // trigger the blur on ensuing pointerup.
  277. // Also to handle cases such as picking a color which would trigger a blur
  278. // in that same tick.
  279. setTimeout(() => {
  280. editable.onblur = handleSubmit;
  281. // case: clicking on the same property → no change → no update → no focus
  282. editable.focus();
  283. });
  284. };
  285. // prevent blur when changing properties from the menu
  286. const onPointerDown = (event: MouseEvent) => {
  287. if (
  288. (event.target instanceof HTMLElement ||
  289. event.target instanceof SVGElement) &&
  290. event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
  291. !isWritableElement(event.target)
  292. ) {
  293. editable.onblur = null;
  294. window.addEventListener("pointerup", bindBlurEvent);
  295. // handle edge-case where pointerup doesn't fire e.g. due to user
  296. // alt-tabbing away
  297. window.addEventListener("blur", handleSubmit);
  298. }
  299. };
  300. // handle updates of textElement properties of editing element
  301. const unbindUpdate = Scene.getScene(element)!.addCallback(() => {
  302. updateWysiwygStyle();
  303. editable.focus();
  304. });
  305. // ---------------------------------------------------------------------------
  306. let isDestroyed = false;
  307. // select on init (focusing is done separately inside the bindBlurEvent()
  308. // because we need it to happen *after* the blur event from `pointerdown`)
  309. editable.select();
  310. bindBlurEvent();
  311. // reposition wysiwyg in case of canvas is resized. Using ResizeObserver
  312. // is preferred so we catch changes from host, where window may not resize.
  313. let observer: ResizeObserver | null = null;
  314. if (canvas && "ResizeObserver" in window) {
  315. observer = new window.ResizeObserver(() => {
  316. updateWysiwygStyle();
  317. });
  318. observer.observe(canvas);
  319. } else {
  320. window.addEventListener("resize", updateWysiwygStyle);
  321. }
  322. window.addEventListener("pointerdown", onPointerDown);
  323. window.addEventListener("wheel", stopEvent, {
  324. passive: false,
  325. capture: true,
  326. });
  327. excalidrawContainer
  328. ?.querySelector(".excalidraw-textEditorContainer")!
  329. .appendChild(editable);
  330. };