textWysiwyg.tsx 21 KB

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