UNPKG

tweak-tools

Version:

Tweak your React projects until awesomeness

236 lines (208 loc) 8.72 kB
import { useEffect, useMemo, useCallback, useState, useRef } from 'react' import { tweakStore } from './store' import { folder } from './helpers' import { useDeepMemo, useValuesForPath } from './hooks' import { useRenderRoot } from './components/Tweak' import type { FolderSettings, Schema, SchemaToValues, StoreType, OnChangeHandler } from './types' import shallow from 'zustand/shallow' type HookSettings = { store?: StoreType } type SchemaOrFn<S extends Schema = Schema> = S | (() => S) type FunctionReturnType<S extends Schema> = [ SchemaToValues<S>, (value: { [K in keyof Partial<SchemaToValues<S, true>>]: SchemaToValues<S, true>[K] }) => void, <T extends keyof SchemaToValues<S, true>>(path: T) => SchemaToValues<S, true>[T] ] type ReturnType<F extends SchemaOrFn> = F extends SchemaOrFn<infer S> ? F extends Function ? FunctionReturnType<S> : SchemaToValues<S> : never type HookReturnType<F extends SchemaOrFn | string, G extends SchemaOrFn> = F extends SchemaOrFn ? ReturnType<F> : ReturnType<G> function parseArgs( schemaOrFolderName: string | SchemaOrFn, settingsOrDepsOrSchema?: HookSettings | React.DependencyList | SchemaOrFn, depsOrSettingsOrFolderSettings?: React.DependencyList | HookSettings | FolderSettings, depsOrSettings?: React.DependencyList | HookSettings, depsOrUndefined?: React.DependencyList ) { let schema: SchemaOrFn let folderName: string | undefined = undefined let folderSettings: FolderSettings | undefined let hookSettings: HookSettings | undefined let deps: React.DependencyList | undefined if (typeof schemaOrFolderName === 'string') { folderName = schemaOrFolderName schema = settingsOrDepsOrSchema as SchemaOrFn if (Array.isArray(depsOrSettingsOrFolderSettings)) { deps = depsOrSettingsOrFolderSettings } else { if (depsOrSettingsOrFolderSettings) { if ('store' in depsOrSettingsOrFolderSettings) { hookSettings = depsOrSettingsOrFolderSettings as HookSettings deps = depsOrSettings as React.DependencyList } else { folderSettings = depsOrSettingsOrFolderSettings as FolderSettings if (Array.isArray(depsOrSettings)) { deps = depsOrSettings as React.DependencyList } else { hookSettings = depsOrSettings as HookSettings deps = depsOrUndefined } } } } } else { schema = schemaOrFolderName as SchemaOrFn if (Array.isArray(settingsOrDepsOrSchema)) { deps = settingsOrDepsOrSchema as React.DependencyList } else { hookSettings = settingsOrDepsOrSchema as HookSettings deps = depsOrSettingsOrFolderSettings as React.DependencyList } } return { schema, folderName, folderSettings, hookSettings, deps: deps || [] } } /** * * @param schemaOrFolderName * @param settingsOrDepsOrSchema * @param folderSettingsOrDeps * @param depsOrUndefined */ export function useTweaks<S extends Schema, F extends SchemaOrFn<S> | string, G extends SchemaOrFn<S>>( schemaOrFolderName: F, settingsOrDepsOrSchema?: HookSettings | React.DependencyList | G, depsOrSettingsOrFolderSettings?: React.DependencyList | HookSettings | FolderSettings, depsOrSettings?: React.DependencyList | HookSettings, depsOrUndefined?: React.DependencyList ): HookReturnType<F, G> { // We parse the args const { folderName, schema, folderSettings, hookSettings, deps } = parseArgs( schemaOrFolderName, settingsOrDepsOrSchema, depsOrSettingsOrFolderSettings, depsOrSettings, depsOrUndefined ) const schemaIsFunction = typeof schema === 'function' // Keep track of deps to see if they changed and if there's need to recompute. const depsChanged = useRef(false) // We will only override the store settings and options when deps have changed // and it isn't the first render const firstRender = useRef(true) // Since the schema object would change on every render, we let the user have // control over when it should trigger a reset of the hook inputs. const _schema = useDeepMemo(() => { depsChanged.current = true const s = typeof schema === 'function' ? schema() : schema return folderName ? { [folderName]: folder(s, folderSettings) } : s }, deps) // GlobalPanel means that no store was provided, therefore we're using the tweakStore const isGlobalPanel = !hookSettings?.store useRenderRoot(isGlobalPanel) const [store] = useState(() => hookSettings?.store || tweakStore) /** * Parses the schema to extract the inputs initial data. * * This initial data will be used to initialize the store. * * Note that getDataFromSchema recursively * parses the schema inside nested folder. */ const [initialData, mappedPaths] = useMemo(() => store.getDataFromSchema(_schema), [store, _schema]) const [allPaths, renderPaths, onChangePaths, onEditStartPaths, onEditEndPaths] = useMemo(() => { const allPaths: string[] = [] const renderPaths: string[] = [] const onChangePaths: Record<string, OnChangeHandler> = {} const onEditStartPaths: Record<string, (...args: any) => void> = {} const onEditEndPaths: Record<string, (...args: any) => void> = {} Object.values(mappedPaths).forEach(({ path, onChange, onEditStart, onEditEnd, transient }) => { allPaths.push(path) if (!!onChange) { onChangePaths[path] = onChange if (!transient) { renderPaths.push(path) } } else { renderPaths.push(path) } if (onEditStart) { onEditStartPaths[path] = onEditStart } if (onEditEnd) { onEditEndPaths[path] = onEditEnd } }) return [allPaths, renderPaths, onChangePaths, onEditStartPaths, onEditEndPaths] }, [mappedPaths]) // Extracts the paths from the initialData and ensures order of paths. const paths = useMemo(() => store.orderPaths(allPaths), [allPaths, store]) /** * Reactive hook returning the values from the store at given paths. * Essentially it flattens the keys of a nested structure. * For example { "folder.subfolder.valueKey": value } becomes { valueKey: value } * * initalData is going to be returned on the first render. Subsequent renders * will call the store data. * */ const values = useValuesForPath(store, renderPaths, initialData) const set = useCallback( (values: Record<string, any>) => { const _values = Object.entries(values).reduce( (acc, [p, v]) => Object.assign(acc, { [mappedPaths[p].path]: v }), {} ) store.set(_values, false) }, [store, mappedPaths] ) const get = useCallback((path: string) => store.get(mappedPaths[path].path), [store, mappedPaths]) useEffect(() => { // We initialize the store with the initialData in useEffect. // Note that doing this while rendering (ie in useMemo) would make // things easier and remove the need for initializing useValuesForPath but // it breaks the ref from Monitor. // we override the settings when deps have changed and this isn't the first // render const shouldOverrideSettings = !firstRender.current && depsChanged.current store.addData(initialData, shouldOverrideSettings) firstRender.current = false depsChanged.current = false return () => store.disposePaths(paths) }, [store, paths, initialData]) useEffect(() => { // let's handle transient subscriptions const unsubscriptions: (() => void)[] = [] Object.entries(onChangePaths).forEach(([path, onChange]) => { onChange(store.get(path), path, { initial: true, get: store.get, ...store.getInput(path)! }) const unsub = store.useStore.subscribe( (s) => { const input = s.data[path] // @ts-ignore const value = input.disabled ? undefined : input.value return [value, input] }, ([value, input]: any) => onChange(value, path, { initial: false, get: store.get, ...input }), { equalityFn: shallow } ) unsubscriptions.push(unsub) }) return () => unsubscriptions.forEach((unsub) => unsub()) }, [store, onChangePaths]) useEffect(() => { const unsubscriptions: Array<() => void> = [] Object.entries(onEditStartPaths).forEach(([path, onEditStart]) => unsubscriptions.push(store.subscribeToEditStart(path, onEditStart)) ) Object.entries(onEditEndPaths).forEach(([path, onEditEnd]) => unsubscriptions.push(store.subscribeToEditEnd(path, onEditEnd)) ) return () => unsubscriptions.forEach((unsub) => unsub()) }, [onEditStartPaths, onEditEndPaths, store]) if (schemaIsFunction) return [values, set, get] as any return values as any }