utils.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. import { FlooredNumber } from "./types";
  2. import { getZoomOrigin } from "./scene";
  3. import { CURSOR_TYPE, FONT_FAMILY } from "./constants";
  4. import { FontFamily, FontString } from "./element/types";
  5. export const SVG_NS = "http://www.w3.org/2000/svg";
  6. let mockDateTime: string | null = null;
  7. export const setDateTimeForTests = (dateTime: string) => {
  8. mockDateTime = dateTime;
  9. };
  10. export const getDateTime = () => {
  11. if (mockDateTime) {
  12. return mockDateTime;
  13. }
  14. const date = new Date();
  15. const year = date.getFullYear();
  16. const month = `${date.getMonth() + 1}`.padStart(2, "0");
  17. const day = `${date.getDate()}`.padStart(2, "0");
  18. const hr = `${date.getHours()}`.padStart(2, "0");
  19. const min = `${date.getMinutes()}`.padStart(2, "0");
  20. return `${year}-${month}-${day}-${hr}${min}`;
  21. };
  22. export const capitalizeString = (str: string) =>
  23. str.charAt(0).toUpperCase() + str.slice(1);
  24. export const isToolIcon = (
  25. target: Element | EventTarget | null,
  26. ): target is HTMLElement =>
  27. target instanceof HTMLElement && target.className.includes("ToolIcon");
  28. export const isInputLike = (
  29. target: Element | EventTarget | null,
  30. ): target is
  31. | HTMLInputElement
  32. | HTMLTextAreaElement
  33. | HTMLSelectElement
  34. | HTMLBRElement
  35. | HTMLDivElement =>
  36. (target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
  37. target instanceof HTMLBRElement || // newline in wysiwyg
  38. target instanceof HTMLInputElement ||
  39. target instanceof HTMLTextAreaElement ||
  40. target instanceof HTMLSelectElement;
  41. export const isWritableElement = (
  42. target: Element | EventTarget | null,
  43. ): target is
  44. | HTMLInputElement
  45. | HTMLTextAreaElement
  46. | HTMLBRElement
  47. | HTMLDivElement =>
  48. (target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
  49. target instanceof HTMLBRElement || // newline in wysiwyg
  50. target instanceof HTMLTextAreaElement ||
  51. (target instanceof HTMLInputElement &&
  52. (target.type === "text" || target.type === "number"));
  53. export const getFontFamilyString = ({
  54. fontFamily,
  55. }: {
  56. fontFamily: FontFamily;
  57. }) => {
  58. return FONT_FAMILY[fontFamily];
  59. };
  60. /** returns fontSize+fontFamily string for assignment to DOM elements */
  61. export const getFontString = ({
  62. fontSize,
  63. fontFamily,
  64. }: {
  65. fontSize: number;
  66. fontFamily: FontFamily;
  67. }) => {
  68. return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString;
  69. };
  70. // https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
  71. export const measureText = (text: string, font: FontString) => {
  72. const line = document.createElement("div");
  73. const body = document.body;
  74. line.style.position = "absolute";
  75. line.style.whiteSpace = "pre";
  76. line.style.font = font;
  77. body.appendChild(line);
  78. line.innerText = text
  79. .split("\n")
  80. // replace empty lines with single space because leading/trailing empty
  81. // lines would be stripped from computation
  82. .map((x) => x || " ")
  83. .join("\n");
  84. const width = line.offsetWidth;
  85. const height = line.offsetHeight;
  86. // Now creating 1px sized item that will be aligned to baseline
  87. // to calculate baseline shift
  88. const span = document.createElement("span");
  89. span.style.display = "inline-block";
  90. span.style.overflow = "hidden";
  91. span.style.width = "1px";
  92. span.style.height = "1px";
  93. line.appendChild(span);
  94. // Baseline is important for positioning text on canvas
  95. const baseline = span.offsetTop + span.offsetHeight;
  96. document.body.removeChild(line);
  97. return { width, height, baseline };
  98. };
  99. export const debounce = <T extends any[]>(
  100. fn: (...args: T) => void,
  101. timeout: number,
  102. ) => {
  103. let handle = 0;
  104. let lastArgs: T;
  105. const ret = (...args: T) => {
  106. lastArgs = args;
  107. clearTimeout(handle);
  108. handle = window.setTimeout(() => fn(...args), timeout);
  109. };
  110. ret.flush = () => {
  111. clearTimeout(handle);
  112. fn(...lastArgs);
  113. };
  114. return ret;
  115. };
  116. export const selectNode = (node: Element) => {
  117. const selection = window.getSelection();
  118. if (selection) {
  119. const range = document.createRange();
  120. range.selectNodeContents(node);
  121. selection.removeAllRanges();
  122. selection.addRange(range);
  123. }
  124. };
  125. export const removeSelection = () => {
  126. const selection = window.getSelection();
  127. if (selection) {
  128. selection.removeAllRanges();
  129. }
  130. };
  131. export const distance = (x: number, y: number) => Math.abs(x - y);
  132. export const resetCursor = () => {
  133. document.documentElement.style.cursor = "";
  134. };
  135. export const setCursorForShape = (shape: string) => {
  136. if (shape === "selection") {
  137. resetCursor();
  138. } else {
  139. document.documentElement.style.cursor = CURSOR_TYPE.CROSSHAIR;
  140. }
  141. };
  142. export const isFullScreen = () =>
  143. document.fullscreenElement?.nodeName === "HTML";
  144. export const allowFullScreen = () =>
  145. document.documentElement.requestFullscreen();
  146. export const exitFullScreen = () => document.exitFullscreen();
  147. export const getShortcutKey = (shortcut: string): string => {
  148. const isMac = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform);
  149. if (isMac) {
  150. return `${shortcut
  151. .replace(/\bCtrlOrCmd\b/i, "Cmd")
  152. .replace(/\bAlt\b/i, "Option")
  153. .replace(/\bDel\b/i, "Delete")
  154. .replace(/\b(Enter|Return)\b/i, "Enter")}`;
  155. }
  156. return `${shortcut.replace(/\bCtrlOrCmd\b/i, "Ctrl")}`;
  157. };
  158. export const viewportCoordsToSceneCoords = (
  159. { clientX, clientY }: { clientX: number; clientY: number },
  160. {
  161. scrollX,
  162. scrollY,
  163. zoom,
  164. }: {
  165. scrollX: FlooredNumber;
  166. scrollY: FlooredNumber;
  167. zoom: number;
  168. },
  169. canvas: HTMLCanvasElement | null,
  170. scale: number,
  171. ) => {
  172. const zoomOrigin = getZoomOrigin(canvas, scale);
  173. const clientXWithZoom = zoomOrigin.x + (clientX - zoomOrigin.x) / zoom;
  174. const clientYWithZoom = zoomOrigin.y + (clientY - zoomOrigin.y) / zoom;
  175. const x = clientXWithZoom - scrollX;
  176. const y = clientYWithZoom - scrollY;
  177. return { x, y };
  178. };
  179. export const sceneCoordsToViewportCoords = (
  180. { sceneX, sceneY }: { sceneX: number; sceneY: number },
  181. {
  182. scrollX,
  183. scrollY,
  184. zoom,
  185. }: {
  186. scrollX: FlooredNumber;
  187. scrollY: FlooredNumber;
  188. zoom: number;
  189. },
  190. canvas: HTMLCanvasElement | null,
  191. scale: number,
  192. ) => {
  193. const zoomOrigin = getZoomOrigin(canvas, scale);
  194. const x = zoomOrigin.x - (zoomOrigin.x - sceneX - scrollX) * zoom;
  195. const y = zoomOrigin.y - (zoomOrigin.y - sceneY - scrollY) * zoom;
  196. return { x, y };
  197. };
  198. export const getGlobalCSSVariable = (name: string) =>
  199. getComputedStyle(document.documentElement).getPropertyValue(`--${name}`);
  200. const RS_LTR_CHARS =
  201. "A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF" +
  202. "\u2C00-\uFB1C\uFDFE-\uFE6F\uFEFD-\uFFFF";
  203. const RS_RTL_CHARS = "\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC";
  204. const RE_RTL_CHECK = new RegExp(`^[^${RS_LTR_CHARS}]*[${RS_RTL_CHARS}]`);
  205. /**
  206. * Checks whether first directional character is RTL. Meaning whether it starts
  207. * with RTL characters, or indeterminate (numbers etc.) characters followed by
  208. * RTL.
  209. * See https://github.com/excalidraw/excalidraw/pull/1722#discussion_r436340171
  210. */
  211. export const isRTL = (text: string) => {
  212. return RE_RTL_CHECK.test(text);
  213. };
  214. export function tupleToCoors(
  215. xyTuple: [number, number],
  216. ): { x: number; y: number } {
  217. const [x, y] = xyTuple;
  218. return { x, y };
  219. }