utils.ts 12 KB

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