import type { Emitter } from "mitt"; import { type DeepReadonly, type Ref, watchEffect, toRef, effectScope } from "vue"; import type * as z from "zod"; import { getI18n } from "./i18n"; // https://stackoverflow.com/a/62085569/242365 export type DistributedKeyOf = T extends any ? keyof T : never; export type AnyRef = T | Ref | (() => T); let idCounter = 1; export function getUniqueId(scope = ""): string { return `${scope ? `${scope}-` : ""}${idCounter++}`; } export function useEventListener, EventType extends keyof EventMap>(emitter: AnyRef | DeepReadonly> | undefined>, type: EventType, listener: (data: EventMap[EventType]) => void): void { const emitterRef = toRef(emitter); watchEffect((onCleanup) => { if (emitterRef.value) { const val = emitterRef.value; val.on(type, listener); onCleanup(() => { val.off(type, listener); }); } }); } export function useDomEventListener>(element: AnyRef, ...args: Args): void { watchEffect((onCleanup) => { const elementRef = toRef(element); if (elementRef.value) { const el = elementRef.value as any; el.addEventListener(...args); onCleanup(() => { el.removeEventListener(...args); }); } }); } /** * An event whose handler can be delayed in a similar fashion to the native ExtendableEvent * (https://developer.mozilla.org/en-US/docs/Web/API/ExtendableEvent/ExtendableEvent). * This enables a pattern described here: https://github.com/vuejs/vue/issues/5443#issuecomment-379284227 * as a workaround for the fact that Vue event handlers cannot be async. */ export interface ExtendableEventMixin { waitUntil(promise: Promise): void; _hasAwaited?: boolean; _promises?: Array>; _awaitPromises(): Promise; } export const extendableEventMixin: ExtendableEventMixin = { waitUntil(promise) { if (this._hasAwaited) { throw new Error("Cannot call waitUntil() after event has been processed."); } else if (this._promises) { this._promises.push(promise); } else { // eslint-disable-next-line @typescript-eslint/no-floating-promises this._promises = [promise]; } }, async _awaitPromises() { if (this._hasAwaited) { throw new Error("Event has already been awaited."); } else { this._hasAwaited = true; await Promise.all(this._promises ?? []); } } } export function validations(val: V, funcs: Array<(val: V) => string | undefined>): string | undefined { for (const func of funcs) { const result = func(val); if (result) { return result; } } return undefined; } export function validateRequired(val: any): string | undefined { if (val == null || val === "") { return getI18n().t("utils.required-error"); } } export function getZodValidator(validator: z.ZodType): (val: any) => string | undefined { return (val) => { if (val) { const result = validator.safeParse(val); if (!result.success) { return result.error.format()._errors.join("\n"); } } }; } /** * Registers a focus handler on the given element that does not fire when the focus was given through a click. */ export function useNonClickFocusHandler(element: AnyRef, onFocus: (e: FocusEvent) => void): void { let lastEvent: { timeout: ReturnType; hadMouseDown?: boolean; focusEvent?: FocusEvent; } | undefined; useDomEventListener(element, "mousedown", () => { lastEvent = { ...lastEvent, timeout: lastEvent?.timeout ?? setTimeout(handleTimeout, 0), hadMouseDown: true }; }); useDomEventListener(element, "focus", (e: Event) => { lastEvent = { ...lastEvent, timeout: lastEvent?.timeout ?? setTimeout(handleTimeout, 0), focusEvent: e as FocusEvent }; }); function handleTimeout() { if (lastEvent?.focusEvent && !lastEvent.hadMouseDown) { onFocus(lastEvent.focusEvent); } lastEvent = undefined; } } /** * Registers a click handler on the given element that does not fire when the click is caused by a drag. */ export function useNonDragClickHandler(element: AnyRef, onClick: (e: MouseEvent) => void): void { let hasMoved = false; useDomEventListener(element, "mousedown", () => { hasMoved = false; const scope = effectScope(); scope.run(() => { useDomEventListener(document, "mousemove", () => { hasMoved = true; }, { capture: true }); useDomEventListener(document, "mouseup", () => { scope.stop(); }, { capture: true }); }); }); useDomEventListener(element, "click", (e) => { if (!hasMoved) { onClick(e as MouseEvent); } }); } export function useUnloadHandler(hasUnsavedModifications: AnyRef): void { const hasUnsavedModificationsRef = toRef(hasUnsavedModifications); useDomEventListener(window, "beforeunload", (e) => { if (hasUnsavedModificationsRef.value) { e.preventDefault(); } }); }