utils.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670
  1. import oc from "open-color";
  2. import colors from "./colors";
  3. import {
  4. CURSOR_TYPE,
  5. DEFAULT_VERSION,
  6. EVENT,
  7. FONT_FAMILY,
  8. MIME_TYPES,
  9. THEME,
  10. WINDOWS_EMOJI_FALLBACK_FONT,
  11. } from "./constants";
  12. import { FontFamilyValues, FontString } from "./element/types";
  13. import { AppState, DataURL, LastActiveToolBeforeEraser, Zoom } from "./types";
  14. import { unstable_batchedUpdates } from "react-dom";
  15. import { isDarwin } from "./keys";
  16. import { SHAPES } from "./shapes";
  17. let mockDateTime: string | null = null;
  18. export const setDateTimeForTests = (dateTime: string) => {
  19. mockDateTime = dateTime;
  20. };
  21. export const getDateTime = () => {
  22. if (mockDateTime) {
  23. return mockDateTime;
  24. }
  25. const date = new Date();
  26. const year = date.getFullYear();
  27. const month = `${date.getMonth() + 1}`.padStart(2, "0");
  28. const day = `${date.getDate()}`.padStart(2, "0");
  29. const hr = `${date.getHours()}`.padStart(2, "0");
  30. const min = `${date.getMinutes()}`.padStart(2, "0");
  31. return `${year}-${month}-${day}-${hr}${min}`;
  32. };
  33. export const capitalizeString = (str: string) =>
  34. str.charAt(0).toUpperCase() + str.slice(1);
  35. export const isToolIcon = (
  36. target: Element | EventTarget | null,
  37. ): target is HTMLElement =>
  38. target instanceof HTMLElement && target.className.includes("ToolIcon");
  39. export const isInputLike = (
  40. target: Element | EventTarget | null,
  41. ): target is
  42. | HTMLInputElement
  43. | HTMLTextAreaElement
  44. | HTMLSelectElement
  45. | HTMLBRElement
  46. | HTMLDivElement =>
  47. (target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
  48. target instanceof HTMLBRElement || // newline in wysiwyg
  49. target instanceof HTMLInputElement ||
  50. target instanceof HTMLTextAreaElement ||
  51. target instanceof HTMLSelectElement;
  52. export const isWritableElement = (
  53. target: Element | EventTarget | null,
  54. ): target is
  55. | HTMLInputElement
  56. | HTMLTextAreaElement
  57. | HTMLBRElement
  58. | HTMLDivElement =>
  59. (target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
  60. target instanceof HTMLBRElement || // newline in wysiwyg
  61. target instanceof HTMLTextAreaElement ||
  62. (target instanceof HTMLInputElement &&
  63. (target.type === "text" || target.type === "number"));
  64. export const getFontFamilyString = ({
  65. fontFamily,
  66. }: {
  67. fontFamily: FontFamilyValues;
  68. }) => {
  69. for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) {
  70. if (id === fontFamily) {
  71. return `${fontFamilyString}, ${WINDOWS_EMOJI_FALLBACK_FONT}`;
  72. }
  73. }
  74. return WINDOWS_EMOJI_FALLBACK_FONT;
  75. };
  76. /** returns fontSize+fontFamily string for assignment to DOM elements */
  77. export const getFontString = ({
  78. fontSize,
  79. fontFamily,
  80. }: {
  81. fontSize: number;
  82. fontFamily: FontFamilyValues;
  83. }) => {
  84. return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString;
  85. };
  86. export const debounce = <T extends any[]>(
  87. fn: (...args: T) => void,
  88. timeout: number,
  89. ) => {
  90. let handle = 0;
  91. let lastArgs: T | null = null;
  92. const ret = (...args: T) => {
  93. lastArgs = args;
  94. clearTimeout(handle);
  95. handle = window.setTimeout(() => {
  96. lastArgs = null;
  97. fn(...args);
  98. }, timeout);
  99. };
  100. ret.flush = () => {
  101. clearTimeout(handle);
  102. if (lastArgs) {
  103. const _lastArgs = lastArgs;
  104. lastArgs = null;
  105. fn(..._lastArgs);
  106. }
  107. };
  108. ret.cancel = () => {
  109. lastArgs = null;
  110. clearTimeout(handle);
  111. };
  112. return ret;
  113. };
  114. // throttle callback to execute once per animation frame
  115. export const throttleRAF = <T extends any[]>(fn: (...args: T) => void) => {
  116. let handle: number | null = null;
  117. let lastArgs: T | null = null;
  118. let callback: ((...args: T) => void) | null = null;
  119. const ret = (...args: T) => {
  120. if (process.env.NODE_ENV === "test") {
  121. fn(...args);
  122. return;
  123. }
  124. lastArgs = args;
  125. callback = fn;
  126. if (handle === null) {
  127. handle = window.requestAnimationFrame(() => {
  128. handle = null;
  129. lastArgs = null;
  130. callback = null;
  131. fn(...args);
  132. });
  133. }
  134. };
  135. ret.flush = () => {
  136. if (handle !== null) {
  137. cancelAnimationFrame(handle);
  138. handle = null;
  139. }
  140. if (lastArgs) {
  141. const _lastArgs = lastArgs;
  142. const _callback = callback;
  143. lastArgs = null;
  144. callback = null;
  145. if (_callback !== null) {
  146. _callback(..._lastArgs);
  147. }
  148. }
  149. };
  150. ret.cancel = () => {
  151. lastArgs = null;
  152. callback = null;
  153. if (handle !== null) {
  154. cancelAnimationFrame(handle);
  155. handle = null;
  156. }
  157. };
  158. return ret;
  159. };
  160. // https://github.com/lodash/lodash/blob/es/chunk.js
  161. export const chunk = <T extends any>(
  162. array: readonly T[],
  163. size: number,
  164. ): T[][] => {
  165. if (!array.length || size < 1) {
  166. return [];
  167. }
  168. let index = 0;
  169. let resIndex = 0;
  170. const result = Array(Math.ceil(array.length / size));
  171. while (index < array.length) {
  172. result[resIndex++] = array.slice(index, (index += size));
  173. }
  174. return result;
  175. };
  176. export const selectNode = (node: Element) => {
  177. const selection = window.getSelection();
  178. if (selection) {
  179. const range = document.createRange();
  180. range.selectNodeContents(node);
  181. selection.removeAllRanges();
  182. selection.addRange(range);
  183. }
  184. };
  185. export const removeSelection = () => {
  186. const selection = window.getSelection();
  187. if (selection) {
  188. selection.removeAllRanges();
  189. }
  190. };
  191. export const distance = (x: number, y: number) => Math.abs(x - y);
  192. export const updateActiveTool = (
  193. appState: Pick<AppState, "activeTool">,
  194. data: (
  195. | { type: typeof SHAPES[number]["value"] | "eraser" }
  196. | { type: "custom"; customType: string }
  197. ) & { lastActiveToolBeforeEraser?: LastActiveToolBeforeEraser },
  198. ): AppState["activeTool"] => {
  199. if (data.type === "custom") {
  200. return {
  201. ...appState.activeTool,
  202. type: "custom",
  203. customType: data.customType,
  204. };
  205. }
  206. return {
  207. ...appState.activeTool,
  208. lastActiveToolBeforeEraser:
  209. data.lastActiveToolBeforeEraser === undefined
  210. ? appState.activeTool.lastActiveToolBeforeEraser
  211. : data.lastActiveToolBeforeEraser,
  212. type: data.type,
  213. customType: null,
  214. };
  215. };
  216. export const resetCursor = (canvas: HTMLCanvasElement | null) => {
  217. if (canvas) {
  218. canvas.style.cursor = "";
  219. }
  220. };
  221. export const setCursor = (canvas: HTMLCanvasElement | null, cursor: string) => {
  222. if (canvas) {
  223. canvas.style.cursor = cursor;
  224. }
  225. };
  226. let eraserCanvasCache: any;
  227. let previewDataURL: string;
  228. export const setEraserCursor = (
  229. canvas: HTMLCanvasElement | null,
  230. theme: AppState["theme"],
  231. ) => {
  232. const cursorImageSizePx = 20;
  233. const drawCanvas = () => {
  234. const isDarkTheme = theme === THEME.DARK;
  235. eraserCanvasCache = document.createElement("canvas");
  236. eraserCanvasCache.theme = theme;
  237. eraserCanvasCache.height = cursorImageSizePx;
  238. eraserCanvasCache.width = cursorImageSizePx;
  239. const context = eraserCanvasCache.getContext("2d")!;
  240. context.lineWidth = 1;
  241. context.beginPath();
  242. context.arc(
  243. eraserCanvasCache.width / 2,
  244. eraserCanvasCache.height / 2,
  245. 5,
  246. 0,
  247. 2 * Math.PI,
  248. );
  249. context.fillStyle = isDarkTheme ? oc.black : oc.white;
  250. context.fill();
  251. context.strokeStyle = isDarkTheme ? oc.white : oc.black;
  252. context.stroke();
  253. previewDataURL = eraserCanvasCache.toDataURL(MIME_TYPES.svg) as DataURL;
  254. };
  255. if (!eraserCanvasCache || eraserCanvasCache.theme !== theme) {
  256. drawCanvas();
  257. }
  258. setCursor(
  259. canvas,
  260. `url(${previewDataURL}) ${cursorImageSizePx / 2} ${
  261. cursorImageSizePx / 2
  262. }, auto`,
  263. );
  264. };
  265. export const setCursorForShape = (
  266. canvas: HTMLCanvasElement | null,
  267. appState: AppState,
  268. ) => {
  269. if (!canvas) {
  270. return;
  271. }
  272. if (appState.activeTool.type === "selection") {
  273. resetCursor(canvas);
  274. } else if (appState.activeTool.type === "eraser") {
  275. setEraserCursor(canvas, appState.theme);
  276. // do nothing if image tool is selected which suggests there's
  277. // a image-preview set as the cursor
  278. // Ignore custom type as well and let host decide
  279. } else if (!["image", "custom"].includes(appState.activeTool.type)) {
  280. canvas.style.cursor = CURSOR_TYPE.CROSSHAIR;
  281. }
  282. };
  283. export const isFullScreen = () =>
  284. document.fullscreenElement?.nodeName === "HTML";
  285. export const allowFullScreen = () =>
  286. document.documentElement.requestFullscreen();
  287. export const exitFullScreen = () => document.exitFullscreen();
  288. export const getShortcutKey = (shortcut: string): string => {
  289. shortcut = shortcut
  290. .replace(/\bAlt\b/i, "Alt")
  291. .replace(/\bShift\b/i, "Shift")
  292. .replace(/\b(Enter|Return)\b/i, "Enter")
  293. .replace(/\bDel\b/i, "Delete");
  294. if (isDarwin) {
  295. return shortcut
  296. .replace(/\bCtrlOrCmd\b/i, "Cmd")
  297. .replace(/\bAlt\b/i, "Option");
  298. }
  299. return shortcut.replace(/\bCtrlOrCmd\b/i, "Ctrl");
  300. };
  301. export const viewportCoordsToSceneCoords = (
  302. { clientX, clientY }: { clientX: number; clientY: number },
  303. {
  304. zoom,
  305. offsetLeft,
  306. offsetTop,
  307. scrollX,
  308. scrollY,
  309. }: {
  310. zoom: Zoom;
  311. offsetLeft: number;
  312. offsetTop: number;
  313. scrollX: number;
  314. scrollY: number;
  315. },
  316. ) => {
  317. const invScale = 1 / zoom.value;
  318. const x = (clientX - offsetLeft) * invScale - scrollX;
  319. const y = (clientY - offsetTop) * invScale - scrollY;
  320. return { x, y };
  321. };
  322. export const sceneCoordsToViewportCoords = (
  323. { sceneX, sceneY }: { sceneX: number; sceneY: number },
  324. {
  325. zoom,
  326. offsetLeft,
  327. offsetTop,
  328. scrollX,
  329. scrollY,
  330. }: {
  331. zoom: Zoom;
  332. offsetLeft: number;
  333. offsetTop: number;
  334. scrollX: number;
  335. scrollY: number;
  336. },
  337. ) => {
  338. const x = (sceneX + scrollX) * zoom.value + offsetLeft;
  339. const y = (sceneY + scrollY) * zoom.value + offsetTop;
  340. return { x, y };
  341. };
  342. export const getGlobalCSSVariable = (name: string) =>
  343. getComputedStyle(document.documentElement).getPropertyValue(`--${name}`);
  344. const RS_LTR_CHARS =
  345. "A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF" +
  346. "\u2C00-\uFB1C\uFDFE-\uFE6F\uFEFD-\uFFFF";
  347. const RS_RTL_CHARS = "\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC";
  348. const RE_RTL_CHECK = new RegExp(`^[^${RS_LTR_CHARS}]*[${RS_RTL_CHARS}]`);
  349. /**
  350. * Checks whether first directional character is RTL. Meaning whether it starts
  351. * with RTL characters, or indeterminate (numbers etc.) characters followed by
  352. * RTL.
  353. * See https://github.com/excalidraw/excalidraw/pull/1722#discussion_r436340171
  354. */
  355. export const isRTL = (text: string) => RE_RTL_CHECK.test(text);
  356. export const tupleToCoors = (
  357. xyTuple: readonly [number, number],
  358. ): { x: number; y: number } => {
  359. const [x, y] = xyTuple;
  360. return { x, y };
  361. };
  362. /** use as a rejectionHandler to mute filesystem Abort errors */
  363. export const muteFSAbortError = (error?: Error) => {
  364. if (error?.name === "AbortError") {
  365. console.warn(error);
  366. return;
  367. }
  368. throw error;
  369. };
  370. export const findIndex = <T>(
  371. array: readonly T[],
  372. cb: (element: T, index: number, array: readonly T[]) => boolean,
  373. fromIndex: number = 0,
  374. ) => {
  375. if (fromIndex < 0) {
  376. fromIndex = array.length + fromIndex;
  377. }
  378. fromIndex = Math.min(array.length, Math.max(fromIndex, 0));
  379. let index = fromIndex - 1;
  380. while (++index < array.length) {
  381. if (cb(array[index], index, array)) {
  382. return index;
  383. }
  384. }
  385. return -1;
  386. };
  387. export const findLastIndex = <T>(
  388. array: readonly T[],
  389. cb: (element: T, index: number, array: readonly T[]) => boolean,
  390. fromIndex: number = array.length - 1,
  391. ) => {
  392. if (fromIndex < 0) {
  393. fromIndex = array.length + fromIndex;
  394. }
  395. fromIndex = Math.min(array.length - 1, Math.max(fromIndex, 0));
  396. let index = fromIndex + 1;
  397. while (--index > -1) {
  398. if (cb(array[index], index, array)) {
  399. return index;
  400. }
  401. }
  402. return -1;
  403. };
  404. export const isTransparent = (color: string) => {
  405. const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0";
  406. const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00";
  407. return (
  408. isRGBTransparent ||
  409. isRRGGBBTransparent ||
  410. color === colors.elementBackground[0]
  411. );
  412. };
  413. export type ResolvablePromise<T> = Promise<T> & {
  414. resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void;
  415. reject: (error: Error) => void;
  416. };
  417. export const resolvablePromise = <T>() => {
  418. let resolve!: any;
  419. let reject!: any;
  420. const promise = new Promise((_resolve, _reject) => {
  421. resolve = _resolve;
  422. reject = _reject;
  423. });
  424. (promise as any).resolve = resolve;
  425. (promise as any).reject = reject;
  426. return promise as ResolvablePromise<T>;
  427. };
  428. /**
  429. * @param func handler taking at most single parameter (event).
  430. */
  431. export const withBatchedUpdates = <
  432. TFunction extends ((event: any) => void) | (() => void),
  433. >(
  434. func: Parameters<TFunction>["length"] extends 0 | 1 ? TFunction : never,
  435. ) =>
  436. ((event) => {
  437. unstable_batchedUpdates(func as TFunction, event);
  438. }) as TFunction;
  439. /**
  440. * barches React state updates and throttles the calls to a single call per
  441. * animation frame
  442. */
  443. export const withBatchedUpdatesThrottled = <
  444. TFunction extends ((event: any) => void) | (() => void),
  445. >(
  446. func: Parameters<TFunction>["length"] extends 0 | 1 ? TFunction : never,
  447. ) => {
  448. // @ts-ignore
  449. return throttleRAF<Parameters<TFunction>>(((event) => {
  450. unstable_batchedUpdates(func, event);
  451. }) as TFunction);
  452. };
  453. //https://stackoverflow.com/a/9462382/8418
  454. export const nFormatter = (num: number, digits: number): string => {
  455. const si = [
  456. { value: 1, symbol: "b" },
  457. { value: 1e3, symbol: "k" },
  458. { value: 1e6, symbol: "M" },
  459. { value: 1e9, symbol: "G" },
  460. ];
  461. const rx = /\.0+$|(\.[0-9]*[1-9])0+$/;
  462. let index;
  463. for (index = si.length - 1; index > 0; index--) {
  464. if (num >= si[index].value) {
  465. break;
  466. }
  467. }
  468. return (
  469. (num / si[index].value).toFixed(digits).replace(rx, "$1") + si[index].symbol
  470. );
  471. };
  472. export const getVersion = () => {
  473. return (
  474. document.querySelector<HTMLMetaElement>('meta[name="version"]')?.content ||
  475. DEFAULT_VERSION
  476. );
  477. };
  478. // Adapted from https://github.com/Modernizr/Modernizr/blob/master/feature-detects/emoji.js
  479. export const supportsEmoji = () => {
  480. const canvas = document.createElement("canvas");
  481. const ctx = canvas.getContext("2d");
  482. if (!ctx) {
  483. return false;
  484. }
  485. const offset = 12;
  486. ctx.fillStyle = "#f00";
  487. ctx.textBaseline = "top";
  488. ctx.font = "32px Arial";
  489. // Modernizr used 🐨, but it is sort of supported on Windows 7.
  490. // Luckily 😀 isn't supported.
  491. ctx.fillText("😀", 0, 0);
  492. return ctx.getImageData(offset, offset, 1, 1).data[0] !== 0;
  493. };
  494. export const getNearestScrollableContainer = (
  495. element: HTMLElement,
  496. ): HTMLElement | Document => {
  497. let parent = element.parentElement;
  498. while (parent) {
  499. if (parent === document.body) {
  500. return document;
  501. }
  502. const { overflowY } = window.getComputedStyle(parent);
  503. const hasScrollableContent = parent.scrollHeight > parent.clientHeight;
  504. if (
  505. hasScrollableContent &&
  506. (overflowY === "auto" ||
  507. overflowY === "scroll" ||
  508. overflowY === "overlay")
  509. ) {
  510. return parent;
  511. }
  512. parent = parent.parentElement;
  513. }
  514. return document;
  515. };
  516. export const focusNearestParent = (element: HTMLInputElement) => {
  517. let parent = element.parentElement;
  518. while (parent) {
  519. if (parent.tabIndex > -1) {
  520. parent.focus();
  521. return;
  522. }
  523. parent = parent.parentElement;
  524. }
  525. };
  526. export const preventUnload = (event: BeforeUnloadEvent) => {
  527. event.preventDefault();
  528. // NOTE: modern browsers no longer allow showing a custom message here
  529. event.returnValue = "";
  530. };
  531. export const bytesToHexString = (bytes: Uint8Array) => {
  532. return Array.from(bytes)
  533. .map((byte) => `0${byte.toString(16)}`.slice(-2))
  534. .join("");
  535. };
  536. export const getUpdatedTimestamp = () => (isTestEnv() ? 1 : Date.now());
  537. /**
  538. * Transforms array of objects containing `id` attribute,
  539. * or array of ids (strings), into a Map, keyd by `id`.
  540. */
  541. export const arrayToMap = <T extends { id: string } | string>(
  542. items: readonly T[],
  543. ) => {
  544. return items.reduce((acc: Map<string, T>, element) => {
  545. acc.set(typeof element === "string" ? element : element.id, element);
  546. return acc;
  547. }, new Map());
  548. };
  549. export const isTestEnv = () =>
  550. typeof process !== "undefined" && process.env?.NODE_ENV === "test";
  551. export const isProdEnv = () =>
  552. typeof process !== "undefined" && process.env?.NODE_ENV === "production";
  553. export const wrapEvent = <T extends Event>(name: EVENT, nativeEvent: T) => {
  554. return new CustomEvent(name, {
  555. detail: {
  556. nativeEvent,
  557. },
  558. cancelable: true,
  559. });
  560. };
  561. export const updateObject = <T extends Record<string, any>>(
  562. obj: T,
  563. updates: Partial<T>,
  564. ): T => {
  565. let didChange = false;
  566. for (const key in updates) {
  567. const value = (updates as any)[key];
  568. if (typeof value !== "undefined") {
  569. if (
  570. (obj as any)[key] === value &&
  571. // if object, always update because its attrs could have changed
  572. (typeof value !== "object" || value === null)
  573. ) {
  574. continue;
  575. }
  576. didChange = true;
  577. }
  578. }
  579. if (!didChange) {
  580. return obj;
  581. }
  582. return {
  583. ...obj,
  584. ...updates,
  585. };
  586. };
  587. export const isPrimitive = (val: any) => {
  588. const type = typeof val;
  589. return val == null || (type !== "object" && type !== "function");
  590. };
  591. export const getFrame = () => {
  592. try {
  593. return window.self === window.top ? "top" : "iframe";
  594. } catch (error) {
  595. return "iframe";
  596. }
  597. };
  598. export const isPromiseLike = (
  599. value: any,
  600. ): value is Promise<ResolutionType<typeof value>> => {
  601. return (
  602. !!value &&
  603. typeof value === "object" &&
  604. "then" in value &&
  605. "catch" in value &&
  606. "finally" in value
  607. );
  608. };