utils.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. import colors from "./colors";
  2. import {
  3. CURSOR_TYPE,
  4. FONT_FAMILY,
  5. WINDOWS_EMOJI_FALLBACK_FONT,
  6. } from "./constants";
  7. import { FontFamily, FontString } from "./element/types";
  8. import { Zoom } from "./types";
  9. import { unstable_batchedUpdates } from "react-dom";
  10. export const SVG_NS = "http://www.w3.org/2000/svg";
  11. let mockDateTime: string | null = null;
  12. export const setDateTimeForTests = (dateTime: string) => {
  13. mockDateTime = dateTime;
  14. };
  15. export const getDateTime = () => {
  16. if (mockDateTime) {
  17. return mockDateTime;
  18. }
  19. const date = new Date();
  20. const year = date.getFullYear();
  21. const month = `${date.getMonth() + 1}`.padStart(2, "0");
  22. const day = `${date.getDate()}`.padStart(2, "0");
  23. const hr = `${date.getHours()}`.padStart(2, "0");
  24. const min = `${date.getMinutes()}`.padStart(2, "0");
  25. return `${year}-${month}-${day}-${hr}${min}`;
  26. };
  27. export const capitalizeString = (str: string) =>
  28. str.charAt(0).toUpperCase() + str.slice(1);
  29. export const isToolIcon = (
  30. target: Element | EventTarget | null,
  31. ): target is HTMLElement =>
  32. target instanceof HTMLElement && target.className.includes("ToolIcon");
  33. export const isInputLike = (
  34. target: Element | EventTarget | null,
  35. ): target is
  36. | HTMLInputElement
  37. | HTMLTextAreaElement
  38. | HTMLSelectElement
  39. | HTMLBRElement
  40. | HTMLDivElement =>
  41. (target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
  42. target instanceof HTMLBRElement || // newline in wysiwyg
  43. target instanceof HTMLInputElement ||
  44. target instanceof HTMLTextAreaElement ||
  45. target instanceof HTMLSelectElement;
  46. export const isWritableElement = (
  47. target: Element | EventTarget | null,
  48. ): target is
  49. | HTMLInputElement
  50. | HTMLTextAreaElement
  51. | HTMLBRElement
  52. | HTMLDivElement =>
  53. (target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
  54. target instanceof HTMLBRElement || // newline in wysiwyg
  55. target instanceof HTMLTextAreaElement ||
  56. (target instanceof HTMLInputElement &&
  57. (target.type === "text" || target.type === "number"));
  58. export const getFontFamilyString = ({
  59. fontFamily,
  60. }: {
  61. fontFamily: FontFamily;
  62. }) => {
  63. return `${FONT_FAMILY[fontFamily]}, ${WINDOWS_EMOJI_FALLBACK_FONT}`;
  64. };
  65. /** returns fontSize+fontFamily string for assignment to DOM elements */
  66. export const getFontString = ({
  67. fontSize,
  68. fontFamily,
  69. }: {
  70. fontSize: number;
  71. fontFamily: FontFamily;
  72. }) => {
  73. return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString;
  74. };
  75. // https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
  76. export const measureText = (text: string, font: FontString) => {
  77. const line = document.createElement("div");
  78. const body = document.body;
  79. line.style.position = "absolute";
  80. line.style.whiteSpace = "pre";
  81. line.style.font = font;
  82. body.appendChild(line);
  83. line.innerText = text
  84. .split("\n")
  85. // replace empty lines with single space because leading/trailing empty
  86. // lines would be stripped from computation
  87. .map((x) => x || " ")
  88. .join("\n");
  89. const width = line.offsetWidth;
  90. const height = line.offsetHeight;
  91. // Now creating 1px sized item that will be aligned to baseline
  92. // to calculate baseline shift
  93. const span = document.createElement("span");
  94. span.style.display = "inline-block";
  95. span.style.overflow = "hidden";
  96. span.style.width = "1px";
  97. span.style.height = "1px";
  98. line.appendChild(span);
  99. // Baseline is important for positioning text on canvas
  100. const baseline = span.offsetTop + span.offsetHeight;
  101. document.body.removeChild(line);
  102. return { width, height, baseline };
  103. };
  104. export const debounce = <T extends any[]>(
  105. fn: (...args: T) => void,
  106. timeout: number,
  107. ) => {
  108. let handle = 0;
  109. let lastArgs: T;
  110. const ret = (...args: T) => {
  111. lastArgs = args;
  112. clearTimeout(handle);
  113. handle = window.setTimeout(() => fn(...args), timeout);
  114. };
  115. ret.flush = () => {
  116. clearTimeout(handle);
  117. if (lastArgs) {
  118. fn(...lastArgs);
  119. }
  120. };
  121. ret.cancel = () => {
  122. clearTimeout(handle);
  123. };
  124. return ret;
  125. };
  126. export const selectNode = (node: Element) => {
  127. const selection = window.getSelection();
  128. if (selection) {
  129. const range = document.createRange();
  130. range.selectNodeContents(node);
  131. selection.removeAllRanges();
  132. selection.addRange(range);
  133. }
  134. };
  135. export const removeSelection = () => {
  136. const selection = window.getSelection();
  137. if (selection) {
  138. selection.removeAllRanges();
  139. }
  140. };
  141. export const distance = (x: number, y: number) => Math.abs(x - y);
  142. export const resetCursor = () => {
  143. document.documentElement.style.cursor = "";
  144. };
  145. export const setCursorForShape = (shape: string) => {
  146. if (shape === "selection") {
  147. resetCursor();
  148. } else {
  149. document.documentElement.style.cursor = CURSOR_TYPE.CROSSHAIR;
  150. }
  151. };
  152. export const isFullScreen = () =>
  153. document.fullscreenElement?.nodeName === "HTML";
  154. export const allowFullScreen = () =>
  155. document.documentElement.requestFullscreen();
  156. export const exitFullScreen = () => document.exitFullscreen();
  157. export const getShortcutKey = (shortcut: string): string => {
  158. const isMac = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform);
  159. if (isMac) {
  160. return `${shortcut
  161. .replace(/\bCtrlOrCmd\b/i, "Cmd")
  162. .replace(/\bAlt\b/i, "Option")
  163. .replace(/\bDel\b/i, "Delete")
  164. .replace(/\b(Enter|Return)\b/i, "Enter")}`;
  165. }
  166. return `${shortcut.replace(/\bCtrlOrCmd\b/i, "Ctrl")}`;
  167. };
  168. export const viewportCoordsToSceneCoords = (
  169. { clientX, clientY }: { clientX: number; clientY: number },
  170. {
  171. zoom,
  172. offsetLeft,
  173. offsetTop,
  174. scrollX,
  175. scrollY,
  176. }: {
  177. zoom: Zoom;
  178. offsetLeft: number;
  179. offsetTop: number;
  180. scrollX: number;
  181. scrollY: number;
  182. },
  183. ) => {
  184. const invScale = 1 / zoom.value;
  185. const x = (clientX - zoom.translation.x - offsetLeft) * invScale - scrollX;
  186. const y = (clientY - zoom.translation.y - offsetTop) * invScale - scrollY;
  187. return { x, y };
  188. };
  189. export const sceneCoordsToViewportCoords = (
  190. { sceneX, sceneY }: { sceneX: number; sceneY: number },
  191. {
  192. zoom,
  193. offsetLeft,
  194. offsetTop,
  195. scrollX,
  196. scrollY,
  197. }: {
  198. zoom: Zoom;
  199. offsetLeft: number;
  200. offsetTop: number;
  201. scrollX: number;
  202. scrollY: number;
  203. },
  204. ) => {
  205. const x = (sceneX + scrollX + offsetLeft) * zoom.value + zoom.translation.x;
  206. const y = (sceneY + scrollY + offsetTop) * zoom.value + zoom.translation.y;
  207. return { x, y };
  208. };
  209. export const getGlobalCSSVariable = (name: string) =>
  210. getComputedStyle(document.documentElement).getPropertyValue(`--${name}`);
  211. const RS_LTR_CHARS =
  212. "A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF" +
  213. "\u2C00-\uFB1C\uFDFE-\uFE6F\uFEFD-\uFFFF";
  214. const RS_RTL_CHARS = "\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC";
  215. const RE_RTL_CHECK = new RegExp(`^[^${RS_LTR_CHARS}]*[${RS_RTL_CHARS}]`);
  216. /**
  217. * Checks whether first directional character is RTL. Meaning whether it starts
  218. * with RTL characters, or indeterminate (numbers etc.) characters followed by
  219. * RTL.
  220. * See https://github.com/excalidraw/excalidraw/pull/1722#discussion_r436340171
  221. */
  222. export const isRTL = (text: string) => RE_RTL_CHECK.test(text);
  223. export const tupleToCoors = (
  224. xyTuple: readonly [number, number],
  225. ): { x: number; y: number } => {
  226. const [x, y] = xyTuple;
  227. return { x, y };
  228. };
  229. /** use as a rejectionHandler to mute filesystem Abort errors */
  230. export const muteFSAbortError = (error?: Error) => {
  231. if (error?.name === "AbortError") {
  232. return;
  233. }
  234. throw error;
  235. };
  236. export const findIndex = <T>(
  237. array: readonly T[],
  238. cb: (element: T, index: number, array: readonly T[]) => boolean,
  239. fromIndex: number = 0,
  240. ) => {
  241. if (fromIndex < 0) {
  242. fromIndex = array.length + fromIndex;
  243. }
  244. fromIndex = Math.min(array.length, Math.max(fromIndex, 0));
  245. let index = fromIndex - 1;
  246. while (++index < array.length) {
  247. if (cb(array[index], index, array)) {
  248. return index;
  249. }
  250. }
  251. return -1;
  252. };
  253. export const findLastIndex = <T>(
  254. array: readonly T[],
  255. cb: (element: T, index: number, array: readonly T[]) => boolean,
  256. fromIndex: number = array.length - 1,
  257. ) => {
  258. if (fromIndex < 0) {
  259. fromIndex = array.length + fromIndex;
  260. }
  261. fromIndex = Math.min(array.length - 1, Math.max(fromIndex, 0));
  262. let index = fromIndex + 1;
  263. while (--index > -1) {
  264. if (cb(array[index], index, array)) {
  265. return index;
  266. }
  267. }
  268. return -1;
  269. };
  270. export const isTransparent = (color: string) => {
  271. const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
  272. const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
  273. return (
  274. isRGBTransparent ||
  275. isRRGGBBTransparent ||
  276. color === colors.elementBackground[0]
  277. );
  278. };
  279. export const noop = () => ({});
  280. export type ResolvablePromise<T> = Promise<T> & {
  281. resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void;
  282. reject: (error: Error) => void;
  283. };
  284. export const resolvablePromise = <T>() => {
  285. let resolve!: any;
  286. let reject!: any;
  287. const promise = new Promise((_resolve, _reject) => {
  288. resolve = _resolve;
  289. reject = _reject;
  290. });
  291. (promise as any).resolve = resolve;
  292. (promise as any).reject = reject;
  293. return promise as ResolvablePromise<T>;
  294. };
  295. /**
  296. * @param func handler taking at most single parameter (event).
  297. */
  298. export const withBatchedUpdates = <
  299. TFunction extends ((event: any) => void) | (() => void)
  300. >(
  301. func: Parameters<TFunction>["length"] extends 0 | 1 ? TFunction : never,
  302. ) =>
  303. ((event) => {
  304. unstable_batchedUpdates(func as TFunction, event);
  305. }) as TFunction;
  306. //https://stackoverflow.com/a/9462382/8418
  307. export const nFormatter = (num: number, digits: number): string => {
  308. const si = [
  309. { value: 1, symbol: "b" },
  310. { value: 1e3, symbol: "k" },
  311. { value: 1e6, symbol: "M" },
  312. { value: 1e9, symbol: "G" },
  313. ];
  314. const rx = /\.0+$|(\.[0-9]*[1-9])0+$/;
  315. let index;
  316. for (index = si.length - 1; index > 0; index--) {
  317. if (num >= si[index].value) {
  318. break;
  319. }
  320. }
  321. return (
  322. (num / si[index].value).toFixed(digits).replace(rx, "$1") + si[index].symbol
  323. );
  324. };