diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | 6ec259a0e71174651bae95d4628138bf6fd68742 (patch) | |
| tree | 5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/utils.ts | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/excalidraw/utils.ts')
| -rw-r--r-- | packages/excalidraw/utils.ts | 1245 |
1 files changed, 1245 insertions, 0 deletions
diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts new file mode 100644 index 0000000..e3e18eb --- /dev/null +++ b/packages/excalidraw/utils.ts @@ -0,0 +1,1245 @@ +import Pool from "es6-promise-pool"; +import { average } from "@excalidraw/math"; +import { COLOR_PALETTE } from "./colors"; +import type { EVENT } from "./constants"; +import { + DEFAULT_VERSION, + FONT_FAMILY, + getFontFamilyFallbacks, + isDarwin, + WINDOWS_EMOJI_FALLBACK_FONT, +} from "./constants"; +import type { + ExcalidrawBindableElement, + FontFamilyValues, + FontString, +} from "./element/types"; +import type { + ActiveTool, + AppState, + ToolType, + UnsubscribeCallback, + Zoom, +} from "./types"; +import type { MaybePromise, ResolutionType } from "./utility-types"; + +let mockDateTime: string | null = null; + +export const setDateTimeForTests = (dateTime: string) => { + mockDateTime = dateTime; +}; + +export const getDateTime = () => { + if (mockDateTime) { + return mockDateTime; + } + + const date = new Date(); + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, "0"); + const day = `${date.getDate()}`.padStart(2, "0"); + const hr = `${date.getHours()}`.padStart(2, "0"); + const min = `${date.getMinutes()}`.padStart(2, "0"); + + return `${year}-${month}-${day}-${hr}${min}`; +}; + +export const capitalizeString = (str: string) => + str.charAt(0).toUpperCase() + str.slice(1); + +export const isToolIcon = ( + target: Element | EventTarget | null, +): target is HTMLElement => + target instanceof HTMLElement && target.className.includes("ToolIcon"); + +export const isInputLike = ( + target: Element | EventTarget | null, +): target is + | HTMLInputElement + | HTMLTextAreaElement + | HTMLSelectElement + | HTMLBRElement + | HTMLDivElement => + (target instanceof HTMLElement && target.dataset.type === "wysiwyg") || + target instanceof HTMLBRElement || // newline in wysiwyg + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement; + +export const isInteractive = (target: Element | EventTarget | null) => { + return ( + isInputLike(target) || + (target instanceof Element && !!target.closest("label, button")) + ); +}; + +export const isWritableElement = ( + target: Element | EventTarget | null, +): target is + | HTMLInputElement + | HTMLTextAreaElement + | HTMLBRElement + | HTMLDivElement => + (target instanceof HTMLElement && target.dataset.type === "wysiwyg") || + target instanceof HTMLBRElement || // newline in wysiwyg + target instanceof HTMLTextAreaElement || + (target instanceof HTMLInputElement && + (target.type === "text" || + target.type === "number" || + target.type === "password")); + +export const getFontFamilyString = ({ + fontFamily, +}: { + fontFamily: FontFamilyValues; +}) => { + for (const [fontFamilyString, id] of Object.entries(FONT_FAMILY)) { + if (id === fontFamily) { + // TODO: we should fallback first to generic family names first + return `${fontFamilyString}${getFontFamilyFallbacks(id) + .map((x) => `, ${x}`) + .join("")}`; + } + } + return WINDOWS_EMOJI_FALLBACK_FONT; +}; + +/** returns fontSize+fontFamily string for assignment to DOM elements */ +export const getFontString = ({ + fontSize, + fontFamily, +}: { + fontSize: number; + fontFamily: FontFamilyValues; +}) => { + return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString; +}; + +export const debounce = <T extends any[]>( + fn: (...args: T) => void, + timeout: number, +) => { + let handle = 0; + let lastArgs: T | null = null; + const ret = (...args: T) => { + lastArgs = args; + clearTimeout(handle); + handle = window.setTimeout(() => { + lastArgs = null; + fn(...args); + }, timeout); + }; + ret.flush = () => { + clearTimeout(handle); + if (lastArgs) { + const _lastArgs = lastArgs; + lastArgs = null; + fn(..._lastArgs); + } + }; + ret.cancel = () => { + lastArgs = null; + clearTimeout(handle); + }; + return ret; +}; + +// throttle callback to execute once per animation frame +export const throttleRAF = <T extends any[]>( + fn: (...args: T) => void, + opts?: { trailing?: boolean }, +) => { + let timerId: number | null = null; + let lastArgs: T | null = null; + let lastArgsTrailing: T | null = null; + + const scheduleFunc = (args: T) => { + timerId = window.requestAnimationFrame(() => { + timerId = null; + fn(...args); + lastArgs = null; + if (lastArgsTrailing) { + lastArgs = lastArgsTrailing; + lastArgsTrailing = null; + scheduleFunc(lastArgs); + } + }); + }; + + const ret = (...args: T) => { + if (import.meta.env.MODE === "test") { + fn(...args); + return; + } + lastArgs = args; + if (timerId === null) { + scheduleFunc(lastArgs); + } else if (opts?.trailing) { + lastArgsTrailing = args; + } + }; + ret.flush = () => { + if (timerId !== null) { + cancelAnimationFrame(timerId); + timerId = null; + } + if (lastArgs) { + fn(...(lastArgsTrailing || lastArgs)); + lastArgs = lastArgsTrailing = null; + } + }; + ret.cancel = () => { + lastArgs = lastArgsTrailing = null; + if (timerId !== null) { + cancelAnimationFrame(timerId); + timerId = null; + } + }; + return ret; +}; + +/** + * Exponential ease-out method + * + * @param {number} k - The value to be tweened. + * @returns {number} The tweened value. + */ +export const easeOut = (k: number) => { + return 1 - Math.pow(1 - k, 4); +}; + +const easeOutInterpolate = (from: number, to: number, progress: number) => { + return (to - from) * easeOut(progress) + from; +}; + +/** + * Animates values from `fromValues` to `toValues` using the requestAnimationFrame API. + * Executes the `onStep` callback on each step with the interpolated values. + * Returns a function that can be called to cancel the animation. + * + * @example + * // Example usage: + * const fromValues = { x: 0, y: 0 }; + * const toValues = { x: 100, y: 200 }; + * const onStep = ({x, y}) => { + * setState(x, y) + * }; + * const onCancel = () => { + * console.log("Animation canceled"); + * }; + * + * const cancelAnimation = easeToValuesRAF({ + * fromValues, + * toValues, + * onStep, + * onCancel, + * }); + * + * // To cancel the animation: + * cancelAnimation(); + */ +export const easeToValuesRAF = < + T extends Record<keyof T, number>, + K extends keyof T, +>({ + fromValues, + toValues, + onStep, + duration = 250, + interpolateValue, + onStart, + onEnd, + onCancel, +}: { + fromValues: T; + toValues: T; + /** + * Interpolate a single value. + * Return undefined to be handled by the default interpolator. + */ + interpolateValue?: ( + fromValue: number, + toValue: number, + /** no easing applied */ + progress: number, + key: K, + ) => number | undefined; + onStep: (values: T) => void; + duration?: number; + onStart?: () => void; + onEnd?: () => void; + onCancel?: () => void; +}) => { + let canceled = false; + let frameId = 0; + let startTime: number; + + function step(timestamp: number) { + if (canceled) { + return; + } + if (startTime === undefined) { + startTime = timestamp; + onStart?.(); + } + + const elapsed = Math.min(timestamp - startTime, duration); + const factor = easeOut(elapsed / duration); + + const newValues = {} as T; + + Object.keys(fromValues).forEach((key) => { + const _key = key as keyof T; + const result = ((toValues[_key] - fromValues[_key]) * factor + + fromValues[_key]) as T[keyof T]; + newValues[_key] = result; + }); + + onStep(newValues); + + if (elapsed < duration) { + const progress = elapsed / duration; + + const newValues = {} as T; + + Object.keys(fromValues).forEach((key) => { + const _key = key as K; + const startValue = fromValues[_key]; + const endValue = toValues[_key]; + + let result; + + result = interpolateValue + ? interpolateValue(startValue, endValue, progress, _key) + : easeOutInterpolate(startValue, endValue, progress); + + if (result == null) { + result = easeOutInterpolate(startValue, endValue, progress); + } + + newValues[_key] = result as T[K]; + }); + onStep(newValues); + + frameId = window.requestAnimationFrame(step); + } else { + onStep(toValues); + onEnd?.(); + } + } + + frameId = window.requestAnimationFrame(step); + + return () => { + onCancel?.(); + canceled = true; + window.cancelAnimationFrame(frameId); + }; +}; + +// https://github.com/lodash/lodash/blob/es/chunk.js +export const chunk = <T extends any>( + array: readonly T[], + size: number, +): T[][] => { + if (!array.length || size < 1) { + return []; + } + let index = 0; + let resIndex = 0; + const result = Array(Math.ceil(array.length / size)); + while (index < array.length) { + result[resIndex++] = array.slice(index, (index += size)); + } + return result; +}; + +export const selectNode = (node: Element) => { + const selection = window.getSelection(); + if (selection) { + const range = document.createRange(); + range.selectNodeContents(node); + selection.removeAllRanges(); + selection.addRange(range); + } +}; + +export const removeSelection = () => { + const selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + } +}; + +export const distance = (x: number, y: number) => Math.abs(x - y); + +export const updateActiveTool = ( + appState: Pick<AppState, "activeTool">, + data: (( + | { + type: ToolType; + } + | { type: "custom"; customType: string } + ) & { locked?: boolean }) & { + lastActiveToolBeforeEraser?: ActiveTool | null; + }, +): AppState["activeTool"] => { + if (data.type === "custom") { + return { + ...appState.activeTool, + type: "custom", + customType: data.customType, + locked: data.locked ?? appState.activeTool.locked, + }; + } + + return { + ...appState.activeTool, + lastActiveTool: + data.lastActiveToolBeforeEraser === undefined + ? appState.activeTool.lastActiveTool + : data.lastActiveToolBeforeEraser, + type: data.type, + customType: null, + locked: data.locked ?? appState.activeTool.locked, + }; +}; + +export const isFullScreen = () => + document.fullscreenElement?.nodeName === "HTML"; + +export const allowFullScreen = () => + document.documentElement.requestFullscreen(); + +export const exitFullScreen = () => document.exitFullscreen(); + +export const getShortcutKey = (shortcut: string): string => { + shortcut = shortcut + .replace(/\bAlt\b/i, "Alt") + .replace(/\bShift\b/i, "Shift") + .replace(/\b(Enter|Return)\b/i, "Enter"); + if (isDarwin) { + return shortcut + .replace(/\bCtrlOrCmd\b/gi, "Cmd") + .replace(/\bAlt\b/i, "Option"); + } + return shortcut.replace(/\bCtrlOrCmd\b/gi, "Ctrl"); +}; + +export const viewportCoordsToSceneCoords = ( + { clientX, clientY }: { clientX: number; clientY: number }, + { + zoom, + offsetLeft, + offsetTop, + scrollX, + scrollY, + }: { + zoom: Zoom; + offsetLeft: number; + offsetTop: number; + scrollX: number; + scrollY: number; + }, +) => { + const x = (clientX - offsetLeft) / zoom.value - scrollX; + const y = (clientY - offsetTop) / zoom.value - scrollY; + + return { x, y }; +}; + +export const sceneCoordsToViewportCoords = ( + { sceneX, sceneY }: { sceneX: number; sceneY: number }, + { + zoom, + offsetLeft, + offsetTop, + scrollX, + scrollY, + }: { + zoom: Zoom; + offsetLeft: number; + offsetTop: number; + scrollX: number; + scrollY: number; + }, +) => { + const x = (sceneX + scrollX) * zoom.value + offsetLeft; + const y = (sceneY + scrollY) * zoom.value + offsetTop; + return { x, y }; +}; + +export const getGlobalCSSVariable = (name: string) => + getComputedStyle(document.documentElement).getPropertyValue(`--${name}`); + +const RS_LTR_CHARS = + "A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF" + + "\u2C00-\uFB1C\uFDFE-\uFE6F\uFEFD-\uFFFF"; +const RS_RTL_CHARS = "\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC"; +const RE_RTL_CHECK = new RegExp(`^[^${RS_LTR_CHARS}]*[${RS_RTL_CHARS}]`); +/** + * Checks whether first directional character is RTL. Meaning whether it starts + * with RTL characters, or indeterminate (numbers etc.) characters followed by + * RTL. + * See https://github.com/excalidraw/excalidraw/pull/1722#discussion_r436340171 + */ +export const isRTL = (text: string) => RE_RTL_CHECK.test(text); + +export const tupleToCoors = ( + xyTuple: readonly [number, number], +): { x: number; y: number } => { + const [x, y] = xyTuple; + return { x, y }; +}; + +/** use as a rejectionHandler to mute filesystem Abort errors */ +export const muteFSAbortError = (error?: Error) => { + if (error?.name === "AbortError") { + console.warn(error); + return; + } + throw error; +}; + +export const findIndex = <T>( + array: readonly T[], + cb: (element: T, index: number, array: readonly T[]) => boolean, + fromIndex: number = 0, +) => { + if (fromIndex < 0) { + fromIndex = array.length + fromIndex; + } + fromIndex = Math.min(array.length, Math.max(fromIndex, 0)); + let index = fromIndex - 1; + while (++index < array.length) { + if (cb(array[index], index, array)) { + return index; + } + } + return -1; +}; + +export const findLastIndex = <T>( + array: readonly T[], + cb: (element: T, index: number, array: readonly T[]) => boolean, + fromIndex: number = array.length - 1, +) => { + if (fromIndex < 0) { + fromIndex = array.length + fromIndex; + } + fromIndex = Math.min(array.length - 1, Math.max(fromIndex, 0)); + let index = fromIndex + 1; + while (--index > -1) { + if (cb(array[index], index, array)) { + return index; + } + } + return -1; +}; + +export const isTransparent = (color: string) => { + const isRGBTransparent = color.length === 5 && color.substr(4, 1) === "0"; + const isRRGGBBTransparent = color.length === 9 && color.substr(7, 2) === "00"; + return ( + isRGBTransparent || + isRRGGBBTransparent || + color === COLOR_PALETTE.transparent + ); +}; + +export const isBindingFallthroughEnabled = (el: ExcalidrawBindableElement) => + el.fillStyle !== "solid" || isTransparent(el.backgroundColor); + +export type ResolvablePromise<T> = Promise<T> & { + resolve: [T] extends [undefined] + ? (value?: MaybePromise<Awaited<T>>) => void + : (value: MaybePromise<Awaited<T>>) => void; + reject: (error: Error) => void; +}; +export const resolvablePromise = <T>() => { + let resolve!: any; + let reject!: any; + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + (promise as any).resolve = resolve; + (promise as any).reject = reject; + return promise as ResolvablePromise<T>; +}; + +//https://stackoverflow.com/a/9462382/8418 +export const nFormatter = (num: number, digits: number): string => { + const si = [ + { value: 1, symbol: "b" }, + { value: 1e3, symbol: "k" }, + { value: 1e6, symbol: "M" }, + { value: 1e9, symbol: "G" }, + ]; + const rx = /\.0+$|(\.[0-9]*[1-9])0+$/; + let index; + for (index = si.length - 1; index > 0; index--) { + if (num >= si[index].value) { + break; + } + } + return ( + (num / si[index].value).toFixed(digits).replace(rx, "$1") + si[index].symbol + ); +}; + +export const getVersion = () => { + return ( + document.querySelector<HTMLMetaElement>('meta[name="version"]')?.content || + DEFAULT_VERSION + ); +}; + +// Adapted from https://github.com/Modernizr/Modernizr/blob/master/feature-detects/emoji.js +export const supportsEmoji = () => { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) { + return false; + } + const offset = 12; + ctx.fillStyle = "#f00"; + ctx.textBaseline = "top"; + ctx.font = "32px Arial"; + // Modernizr used 🐨, but it is sort of supported on Windows 7. + // Luckily 😀 isn't supported. + ctx.fillText("😀", 0, 0); + return ctx.getImageData(offset, offset, 1, 1).data[0] !== 0; +}; + +export const getNearestScrollableContainer = ( + element: HTMLElement, +): HTMLElement | Document => { + let parent = element.parentElement; + while (parent) { + if (parent === document.body) { + return document; + } + const { overflowY } = window.getComputedStyle(parent); + const hasScrollableContent = parent.scrollHeight > parent.clientHeight; + if ( + hasScrollableContent && + (overflowY === "auto" || + overflowY === "scroll" || + overflowY === "overlay") + ) { + return parent; + } + parent = parent.parentElement; + } + return document; +}; + +export const focusNearestParent = (element: HTMLInputElement) => { + let parent = element.parentElement; + while (parent) { + if (parent.tabIndex > -1) { + parent.focus(); + return; + } + parent = parent.parentElement; + } +}; + +export const preventUnload = (event: BeforeUnloadEvent) => { + event.preventDefault(); + // NOTE: modern browsers no longer allow showing a custom message here + event.returnValue = ""; +}; + +export const bytesToHexString = (bytes: Uint8Array) => { + return Array.from(bytes) + .map((byte) => `0${byte.toString(16)}`.slice(-2)) + .join(""); +}; + +export const getUpdatedTimestamp = () => (isTestEnv() ? 1 : Date.now()); + +/** + * Transforms array of objects containing `id` attribute, + * or array of ids (strings), into a Map, keyd by `id`. + */ +export const arrayToMap = <T extends { id: string } | string>( + items: readonly T[] | Map<string, T>, +) => { + if (items instanceof Map) { + return items; + } + return items.reduce((acc: Map<string, T>, element) => { + acc.set(typeof element === "string" ? element : element.id, element); + return acc; + }, new Map()); +}; + +export const arrayToMapWithIndex = <T extends { id: string }>( + elements: readonly T[], +) => + elements.reduce((acc, element: T, idx) => { + acc.set(element.id, [element, idx]); + return acc; + }, new Map<string, [element: T, index: number]>()); + +/** + * Transform array into an object, use only when array order is irrelevant. + */ +export const arrayToObject = <T>( + array: readonly T[], + groupBy?: (value: T) => string | number, +) => + array.reduce((acc, value) => { + acc[groupBy ? groupBy(value) : String(value)] = value; + return acc; + }, {} as { [key: string]: T }); + +/** Doubly linked node */ +export type Node<T> = T & { + prev: Node<T> | null; + next: Node<T> | null; +}; + +/** + * Creates a circular doubly linked list by adding `prev` and `next` props to the existing array nodes. + */ +export const arrayToList = <T>(array: readonly T[]): Node<T>[] => + array.reduce((acc, curr, index) => { + const node: Node<T> = { ...curr, prev: null, next: null }; + + // no-op for first item, we don't want circular references on a single item + if (index !== 0) { + const prevNode = acc[index - 1]; + node.prev = prevNode; + prevNode.next = node; + + if (index === array.length - 1) { + // make the references circular and connect head & tail + const firstNode = acc[0]; + node.next = firstNode; + firstNode.prev = node; + } + } + + acc.push(node); + + return acc; + }, [] as Node<T>[]); + +export const isTestEnv = () => import.meta.env.MODE === "test"; + +export const isDevEnv = () => import.meta.env.MODE === "development"; + +export const isServerEnv = () => + typeof process !== "undefined" && !!process?.env?.NODE_ENV; + +export const wrapEvent = <T extends Event>(name: EVENT, nativeEvent: T) => { + return new CustomEvent(name, { + detail: { + nativeEvent, + }, + cancelable: true, + }); +}; + +export const updateObject = <T extends Record<string, any>>( + obj: T, + updates: Partial<T>, +): T => { + let didChange = false; + for (const key in updates) { + const value = (updates as any)[key]; + if (typeof value !== "undefined") { + if ( + (obj as any)[key] === value && + // if object, always update because its attrs could have changed + (typeof value !== "object" || value === null) + ) { + continue; + } + didChange = true; + } + } + + if (!didChange) { + return obj; + } + + return { + ...obj, + ...updates, + }; +}; + +export const isPrimitive = (val: any) => { + const type = typeof val; + return val == null || (type !== "object" && type !== "function"); +}; + +export const getFrame = () => { + try { + return window.self === window.top ? "top" : "iframe"; + } catch (error) { + return "iframe"; + } +}; + +export const isRunningInIframe = () => getFrame() === "iframe"; + +export const isPromiseLike = ( + value: any, +): value is Promise<ResolutionType<typeof value>> => { + return ( + !!value && + typeof value === "object" && + "then" in value && + "catch" in value && + "finally" in value + ); +}; + +export const queryFocusableElements = (container: HTMLElement | null) => { + const focusableElements = container?.querySelectorAll<HTMLElement>( + "button, a, input, select, textarea, div[tabindex], label[tabindex]", + ); + + return focusableElements + ? Array.from(focusableElements).filter( + (element) => + element.tabIndex > -1 && !(element as HTMLInputElement).disabled, + ) + : []; +}; + +/** use as a fallback after identity check (for perf reasons) */ +const _defaultIsShallowComparatorFallback = (a: any, b: any): boolean => { + // consider two empty arrays equal + if ( + Array.isArray(a) && + Array.isArray(b) && + a.length === 0 && + b.length === 0 + ) { + return true; + } + return a === b; +}; + +/** + * Returns whether object/array is shallow equal. + * Considers empty object/arrays as equal (whether top-level or second-level). + */ +export const isShallowEqual = < + T extends Record<string, any>, + K extends readonly unknown[], +>( + objA: T, + objB: T, + comparators?: + | { [key in keyof T]?: (a: T[key], b: T[key]) => boolean } + | (keyof T extends K[number] + ? K extends readonly (keyof T)[] + ? K + : { + _error: "keys are either missing or include keys not in compared obj"; + } + : { + _error: "keys are either missing or include keys not in compared obj"; + }), + debug = false, +) => { + const aKeys = Object.keys(objA); + const bKeys = Object.keys(objB); + if (aKeys.length !== bKeys.length) { + if (debug) { + console.warn( + `%cisShallowEqual: objects don't have same properties ->`, + "color: #8B4000", + objA, + objB, + ); + } + return false; + } + + if (comparators && Array.isArray(comparators)) { + for (const key of comparators) { + const ret = + objA[key] === objB[key] || + _defaultIsShallowComparatorFallback(objA[key], objB[key]); + if (!ret) { + if (debug) { + console.warn( + `%cisShallowEqual: ${key} not equal ->`, + "color: #8B4000", + objA[key], + objB[key], + ); + } + return false; + } + } + return true; + } + + return aKeys.every((key) => { + const comparator = ( + comparators as { [key in keyof T]?: (a: T[key], b: T[key]) => boolean } + )?.[key as keyof T]; + const ret = comparator + ? comparator(objA[key], objB[key]) + : objA[key] === objB[key] || + _defaultIsShallowComparatorFallback(objA[key], objB[key]); + + if (!ret && debug) { + console.warn( + `%cisShallowEqual: ${key} not equal ->`, + "color: #8B4000", + objA[key], + objB[key], + ); + } + return ret; + }); +}; + +// taken from Radix UI +// https://github.com/radix-ui/primitives/blob/main/packages/core/primitive/src/primitive.tsx +export const composeEventHandlers = <E>( + originalEventHandler?: (event: E) => void, + ourEventHandler?: (event: E) => void, + { checkForDefaultPrevented = true } = {}, +) => { + return function handleEvent(event: E) { + originalEventHandler?.(event); + + if ( + !checkForDefaultPrevented || + !(event as unknown as Event)?.defaultPrevented + ) { + return ourEventHandler?.(event); + } + }; +}; + +/** + * supply `null` as message if non-never value is valid, you just need to + * typecheck against it + */ +export const assertNever = ( + value: never, + message: string | null, + softAssert?: boolean, +): never => { + if (!message) { + return value; + } + if (softAssert) { + console.error(message); + return value; + } + + throw new Error(message); +}; + +export function invariant(condition: any, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +/** + * Memoizes on values of `opts` object (strict equality). + */ +export const memoize = <T extends Record<string, any>, R extends any>( + func: (opts: T) => R, +) => { + let lastArgs: Map<string, any> | undefined; + let lastResult: R | undefined; + + const ret = function (opts: T) { + const currentArgs = Object.entries(opts); + + if (lastArgs) { + let argsAreEqual = true; + for (const [key, value] of currentArgs) { + if (lastArgs.get(key) !== value) { + argsAreEqual = false; + break; + } + } + if (argsAreEqual) { + return lastResult; + } + } + + const result = func(opts); + + lastArgs = new Map(currentArgs); + lastResult = result; + + return result; + }; + + ret.clear = () => { + lastArgs = undefined; + lastResult = undefined; + }; + + return ret as typeof func & { clear: () => void }; +}; + +/** Checks if value is inside given collection. Useful for type-safety. */ +export const isMemberOf = <T extends string>( + /** Set/Map/Array/Object */ + collection: Set<T> | readonly T[] | Record<T, any> | Map<T, any>, + /** value to look for */ + value: string, +): value is T => { + return collection instanceof Set || collection instanceof Map + ? collection.has(value as T) + : "includes" in collection + ? collection.includes(value as T) + : collection.hasOwnProperty(value); +}; + +export const cloneJSON = <T>(obj: T): T => JSON.parse(JSON.stringify(obj)); + +export const updateStable = <T extends any[] | Record<string, any>>( + prevValue: T, + nextValue: T, +) => { + if (isShallowEqual(prevValue, nextValue)) { + return prevValue; + } + return nextValue; +}; + +// Window +export function addEventListener<K extends keyof WindowEventMap>( + target: Window & typeof globalThis, + type: K, + listener: (this: Window, ev: WindowEventMap[K]) => any, + options?: boolean | AddEventListenerOptions, +): UnsubscribeCallback; +export function addEventListener( + target: Window & typeof globalThis, + type: string, + listener: (this: Window, ev: Event) => any, + options?: boolean | AddEventListenerOptions, +): UnsubscribeCallback; +// Document +export function addEventListener<K extends keyof DocumentEventMap>( + target: Document, + type: K, + listener: (this: Document, ev: DocumentEventMap[K]) => any, + options?: boolean | AddEventListenerOptions, +): UnsubscribeCallback; +export function addEventListener( + target: Document, + type: string, + listener: (this: Document, ev: Event) => any, + options?: boolean | AddEventListenerOptions, +): UnsubscribeCallback; +// FontFaceSet (document.fonts) +export function addEventListener<K extends keyof FontFaceSetEventMap>( + target: FontFaceSet, + type: K, + listener: (this: FontFaceSet, ev: FontFaceSetEventMap[K]) => any, + options?: boolean | AddEventListenerOptions, +): UnsubscribeCallback; +// HTMLElement / mix +export function addEventListener<K extends keyof HTMLElementEventMap>( + target: + | Document + | (Window & typeof globalThis) + | HTMLElement + | undefined + | null + | false, + type: K, + listener: (this: HTMLDivElement, ev: HTMLElementEventMap[K]) => any, + options?: boolean | AddEventListenerOptions, +): UnsubscribeCallback; +// implem +export function addEventListener( + /** + * allows for falsy values so you don't have to type check when adding + * event listeners to optional elements + */ + target: + | Document + | (Window & typeof globalThis) + | FontFaceSet + | HTMLElement + | undefined + | null + | false, + type: keyof WindowEventMap | keyof DocumentEventMap | string, + listener: (ev: Event) => any, + options?: boolean | AddEventListenerOptions, +): UnsubscribeCallback { + if (!target) { + return () => {}; + } + target?.addEventListener?.(type, listener, options); + return () => { + target?.removeEventListener?.(type, listener, options); + }; +} + +export function getSvgPathFromStroke(points: number[][], closed = true) { + const len = points.length; + + if (len < 4) { + return ``; + } + + let a = points[0]; + let b = points[1]; + const c = points[2]; + + let result = `M${a[0].toFixed(2)},${a[1].toFixed(2)} Q${b[0].toFixed( + 2, + )},${b[1].toFixed(2)} ${average(b[0], c[0]).toFixed(2)},${average( + b[1], + c[1], + ).toFixed(2)} T`; + + for (let i = 2, max = len - 1; i < max; i++) { + a = points[i]; + b = points[i + 1]; + result += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed( + 2, + )} `; + } + + if (closed) { + result += "Z"; + } + + return result; +} + +export const normalizeEOL = (str: string) => { + return str.replace(/\r?\n|\r/g, "\n"); +}; + +// ----------------------------------------------------------------------------- +type HasBrand<T> = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + [K in keyof T]: K extends `~brand${infer _}` ? true : never; +}[keyof T]; + +type RemoveAllBrands<T> = HasBrand<T> extends true + ? { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + [K in keyof T as K extends `~brand~${infer _}` ? never : K]: T[K]; + } + : never; + +// adapted from https://github.com/colinhacks/zod/discussions/1994#discussioncomment-6068940 +// currently does not cover all types (e.g. tuples, promises...) +type Unbrand<T> = T extends Map<infer E, infer F> + ? Map<E, F> + : T extends Set<infer E> + ? Set<E> + : T extends Array<infer E> + ? Array<E> + : RemoveAllBrands<T>; + +/** + * Makes type into a branded type, ensuring that value is assignable to + * the base ubranded type. Optionally you can explicitly supply current value + * type to combine both (useful for composite branded types. Make sure you + * compose branded types which are not composite themselves.) + */ +export const toBrandedType = <BrandedType, CurrentType = BrandedType>( + value: Unbrand<BrandedType>, +) => { + return value as CurrentType & BrandedType; +}; + +// ----------------------------------------------------------------------------- + +// Promise.try, adapted from https://github.com/sindresorhus/p-try +export const promiseTry = async <TValue, TArgs extends unknown[]>( + fn: (...args: TArgs) => PromiseLike<TValue> | TValue, + ...args: TArgs +): Promise<TValue> => { + return new Promise((resolve) => { + resolve(fn(...args)); + }); +}; + +export const isAnyTrue = (...args: boolean[]): boolean => + Math.max(...args.map((arg) => (arg ? 1 : 0))) > 0; + +export const safelyParseJSON = (json: string): Record<string, any> | null => { + try { + return JSON.parse(json); + } catch { + return null; + } +}; +// extending the missing types +// relying on the [Index, T] to keep a correct order +type TPromisePool<T, Index = number> = Pool<[Index, T][]> & { + addEventListener: ( + type: "fulfilled", + listener: (event: { data: { result: [Index, T] } }) => void, + ) => (event: { data: { result: [Index, T] } }) => void; + removeEventListener: ( + type: "fulfilled", + listener: (event: { data: { result: [Index, T] } }) => void, + ) => void; +}; + +export class PromisePool<T> { + private readonly pool: TPromisePool<T>; + private readonly entries: Record<number, T> = {}; + + constructor( + source: IterableIterator<Promise<void | readonly [number, T]>>, + concurrency: number, + ) { + this.pool = new Pool( + source as unknown as () => void | PromiseLike<[number, T][]>, + concurrency, + ) as TPromisePool<T>; + } + + public all() { + const listener = (event: { data: { result: void | [number, T] } }) => { + if (event.data.result) { + // by default pool does not return the results, so we are gathering them manually + // with the correct call order (represented by the index in the tuple) + const [index, value] = event.data.result; + this.entries[index] = value; + } + }; + + this.pool.addEventListener("fulfilled", listener); + + return this.pool.start().then(() => { + setTimeout(() => { + this.pool.removeEventListener("fulfilled", listener); + }); + + return Object.values(this.entries); + }); + } +} + +/** + * use when you need to render unsafe string as HTML attribute, but MAKE SURE + * the attribute is double-quoted when constructing the HTML string + */ +export const escapeDoubleQuotes = (str: string) => { + return str.replace(/"/g, """); +}; + +export const castArray = <T>(value: T | T[]): T[] => + Array.isArray(value) ? value : [value]; |
