You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

utils.ts 13KB

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