utils.ts 19 KB

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