textWysiwyg.tsx 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. import { KEYS } from "../keys";
  2. import { selectNode, isWritableElement } from "../utils";
  3. import { globalSceneState } from "../scene";
  4. import { isTextElement } from "./typeChecks";
  5. import { CLASSES } from "../constants";
  6. function trimText(text: string) {
  7. // whitespace only → trim all because we'd end up inserting invisible element
  8. if (!text.trim()) {
  9. return "";
  10. }
  11. // replace leading/trailing newlines (only) otherwise it messes up bounding
  12. // box calculation (there's also a bug in FF which inserts trailing newline
  13. // for multiline texts)
  14. return text.replace(/^\n+|\n+$/g, "");
  15. }
  16. type TextWysiwygParams = {
  17. id: string;
  18. initText: string;
  19. x: number;
  20. y: number;
  21. strokeColor: string;
  22. font: string;
  23. opacity: number;
  24. zoom: number;
  25. angle: number;
  26. textAlign: string;
  27. onChange?: (text: string) => void;
  28. onSubmit: (text: string) => void;
  29. onCancel: () => void;
  30. };
  31. export function textWysiwyg({
  32. id,
  33. initText,
  34. x,
  35. y,
  36. strokeColor,
  37. font,
  38. opacity,
  39. zoom,
  40. angle,
  41. onChange,
  42. textAlign,
  43. onSubmit,
  44. onCancel,
  45. }: TextWysiwygParams) {
  46. const editable = document.createElement("div");
  47. try {
  48. editable.contentEditable = "plaintext-only";
  49. } catch {
  50. editable.contentEditable = "true";
  51. }
  52. editable.dir = "auto";
  53. editable.tabIndex = 0;
  54. editable.innerText = initText;
  55. editable.dataset.type = "wysiwyg";
  56. const degree = (180 * angle) / Math.PI;
  57. Object.assign(editable.style, {
  58. color: strokeColor,
  59. position: "fixed",
  60. opacity: opacity / 100,
  61. top: `${y}px`,
  62. left: `${x}px`,
  63. transform: `translate(-50%, -50%) scale(${zoom}) rotate(${degree}deg)`,
  64. textAlign: textAlign,
  65. display: "inline-block",
  66. font: font,
  67. padding: "4px",
  68. // This needs to have "1px solid" otherwise the carret doesn't show up
  69. // the first time on Safari and Chrome!
  70. outline: "1px solid transparent",
  71. whiteSpace: "nowrap",
  72. minHeight: "1em",
  73. backfaceVisibility: "hidden",
  74. });
  75. editable.onpaste = (event) => {
  76. try {
  77. const selection = window.getSelection();
  78. if (!selection?.rangeCount) {
  79. return;
  80. }
  81. selection.deleteFromDocument();
  82. const text = event.clipboardData!.getData("text").replace(/\r\n?/g, "\n");
  83. const span = document.createElement("span");
  84. span.innerText = text;
  85. const range = selection.getRangeAt(0);
  86. range.insertNode(span);
  87. // deselect
  88. window.getSelection()!.removeAllRanges();
  89. range.setStart(span, span.childNodes.length);
  90. range.setEnd(span, span.childNodes.length);
  91. selection.addRange(range);
  92. event.preventDefault();
  93. } catch (error) {
  94. console.error(error);
  95. }
  96. };
  97. if (onChange) {
  98. editable.oninput = () => {
  99. onChange(trimText(editable.innerText));
  100. };
  101. }
  102. editable.onkeydown = (event) => {
  103. if (event.key === KEYS.ESCAPE) {
  104. event.preventDefault();
  105. handleSubmit();
  106. } else if (event.key === KEYS.ENTER && event[KEYS.CTRL_OR_CMD]) {
  107. event.preventDefault();
  108. if (event.isComposing || event.keyCode === 229) {
  109. return;
  110. }
  111. handleSubmit();
  112. } else if (event.key === KEYS.ENTER && !event.altKey) {
  113. event.stopPropagation();
  114. }
  115. };
  116. function stopEvent(event: Event) {
  117. event.stopPropagation();
  118. }
  119. function handleSubmit() {
  120. if (editable.innerText) {
  121. onSubmit(trimText(editable.innerText));
  122. } else {
  123. onCancel();
  124. }
  125. cleanup();
  126. }
  127. function cleanup() {
  128. if (isDestroyed) {
  129. return;
  130. }
  131. isDestroyed = true;
  132. // remove events to ensure they don't late-fire
  133. editable.onblur = null;
  134. editable.onpaste = null;
  135. editable.oninput = null;
  136. editable.onkeydown = null;
  137. window.removeEventListener("wheel", stopEvent, true);
  138. window.removeEventListener("pointerdown", onPointerDown);
  139. window.removeEventListener("pointerup", rebindBlur);
  140. window.removeEventListener("blur", handleSubmit);
  141. unbindUpdate();
  142. document.body.removeChild(editable);
  143. }
  144. const rebindBlur = () => {
  145. window.removeEventListener("pointerup", rebindBlur);
  146. // deferred to guard against focus traps on various UIs that steal focus
  147. // upon pointerUp
  148. setTimeout(() => {
  149. editable.onblur = handleSubmit;
  150. // case: clicking on the same property → no change → no update → no focus
  151. editable.focus();
  152. });
  153. };
  154. // prevent blur when changing properties from the menu
  155. const onPointerDown = (event: MouseEvent) => {
  156. if (
  157. event.target instanceof HTMLElement &&
  158. event.target.closest(CLASSES.SHAPE_ACTIONS_MENU) &&
  159. !isWritableElement(event.target)
  160. ) {
  161. editable.onblur = null;
  162. window.addEventListener("pointerup", rebindBlur);
  163. // handle edge-case where pointerup doesn't fire e.g. due to user
  164. // alt-tabbing away
  165. window.addEventListener("blur", handleSubmit);
  166. }
  167. };
  168. // handle updates of textElement properties of editing element
  169. const unbindUpdate = globalSceneState.addCallback(() => {
  170. const editingElement = globalSceneState
  171. .getElementsIncludingDeleted()
  172. .find((element) => element.id === id);
  173. if (editingElement && isTextElement(editingElement)) {
  174. Object.assign(editable.style, {
  175. font: editingElement.font,
  176. textAlign: editingElement.textAlign,
  177. color: editingElement.strokeColor,
  178. opacity: editingElement.opacity / 100,
  179. });
  180. }
  181. editable.focus();
  182. });
  183. let isDestroyed = false;
  184. editable.onblur = handleSubmit;
  185. window.addEventListener("pointerdown", onPointerDown);
  186. window.addEventListener("wheel", stopEvent, true);
  187. document.body.appendChild(editable);
  188. editable.focus();
  189. selectNode(editable);
  190. }