UNPKG

tweak-tools

Version:

Tweak your React projects until awesomeness

316 lines (279 loc) 10.3 kB
import { useMemo } from 'react' import create, { SetState, GetState, StoreApi } from 'zustand' import { subscribeWithSelector } from 'zustand/middleware' import { normalizeInput, join, updateInput, TweakErrors, getUid } from './utils' import { SpecialInputs, MappedPaths, DataInput } from './types' import type { Data, FolderSettings, State, StoreType } from './types' import { createEventEmitter } from './eventEmitter' export const Store = function (this: StoreType) { const store = create( subscribeWithSelector<State, SetState<State>, GetState<State>, StoreApi<State>>(() => ({ data: {} })) ) const eventEmitter = createEventEmitter() this.storeId = getUid() this.useStore = store /** * Folders will hold the folder settings for the pane. * @note possibly make this reactive */ const folders: Record<string, FolderSettings> = {} /** * OrderedPaths will hold all the paths in a parent -> children order. * This will ensure we can display the controls in a predictable order. */ const orderedPaths = new Set<String>() /** * For a given data structure, gets all paths for which inputs have * a reference __refCount superior to zero. This function is used by the * root pane to only display the inputs that are consumed by mounted * components. * * @param data */ this.getVisiblePaths = () => { const data = this.getData() const paths = Object.keys(data) // identifies hiddenFolders const hiddenFolders: string[] = [] Object.entries(folders).forEach(([path, settings]) => { if ( // the folder settings have a render function settings.render && // and the folder path matches a data path // (this can happen on first mount and could probably be solved if folder settings // were set together with the store data. In fact, the store data is set in useEffect // while folders settings are set in useMemo). paths.some((p) => p.indexOf(path) === 0) && // the folder settings is supposed to be hidden !settings.render(this.get) ) // then folder is hidden hiddenFolders.push(path + '.') }) const visiblePaths: string[] = [] orderedPaths.forEach((path: any) => { if ( path in data && // if input is mounted data[path].__refCount > 0 && // if it's not included in a hidden folder hiddenFolders.every((p) => path.indexOf(p) === -1) && // if its render functions doesn't exists or returns true (!data[path].render || data[path].render!(this.get)) ) { // then the input path is visible visiblePaths.push(path) } }) return visiblePaths } // adds paths to OrderedPaths this.setOrderedPaths = (newPaths) => { newPaths.forEach((p) => orderedPaths.add(p)) } this.orderPaths = (paths) => { this.setOrderedPaths(paths) return paths } /** * When the useTweak hook unmmounts, it will call this function that will * decrease the __refCount of all the inputs. When an input __refCount reaches 0, it * should no longer be displayed in the panel. * * @param paths */ this.disposePaths = (paths) => { store.setState((s) => { const data = s.data paths.forEach((path) => { if (path in data) { const input = data[path] input.__refCount-- if (input.__refCount === 0 && input.type in SpecialInputs) { // this makes sure special inputs such as buttons are properly // refreshed. This might need some attention though. delete data[path] } } }) return { data } }) } this.dispose = () => { store.setState(() => { return { data: {} } }) } this.getFolderSettings = (path) => { return folders[path] || {} } // Shorthand to get zustand store data this.getData = () => { return store.getState().data } /** * Merges the data passed as an argument with the store data. * If an input path from the data already exists in the store, * the function doesn't update the data but increments __refCount * to keep track of how many components use that input key. * * Uses depsChanged to trigger a recompute and update inputs * settings if needed. * * @param newData the data to update * @param depsChanged to keep track of dependencies */ this.addData = (newData, override) => { store.setState((s) => { const data = s.data Object.entries(newData).forEach(([path, newInputData]) => { let input = data[path] // If an input already exists compare its values and increase the reference __refCount. if (!!input) { // @ts-ignore const { type, value, ...rest } = newInputData if (type !== input.type) { console.warn(TweakErrors.INPUT_TYPE_OVERRIDE, input.type, type) } else { if (input.__refCount === 0 || override) { Object.assign(input, rest) } // Else we increment the ref count input.__refCount++ } } else { data[path] = { ...newInputData, __refCount: 1 } } }) // Since we're returning a new object, direct mutation of data // Should trigger a re-render so we're good! return { data } }) } /** * Shorthand function to set the value of an input at a given path. * * @param path path of the input * @param value new value of the input */ this.setValueAtPath = (path, value, fromPanel) => { store.setState((s) => { const data = s.data //@ts-expect-error (we always update inputs with a value) updateInput(data[path], value, path, this, fromPanel) return { data } }) } this.setSettingsAtPath = (path, settings) => { store.setState((s) => { const data = s.data //@ts-expect-error (we always update inputs with settings) data[path].settings = { ...data[path].settings, ...settings } return { data } }) } this.disableInputAtPath = (path, flag) => { store.setState((s) => { const data = s.data //@ts-expect-error (we always update inputs with a value) data[path].disabled = flag return { data } }) } this.set = (values, fromPanel: boolean) => { store.setState((s) => { const data = s.data Object.entries(values).forEach(([path, value]) => { try { //@ts-expect-error (we always update inputs with a value) updateInput(data[path], value, undefined, undefined, fromPanel) } catch (e) { if (process.env.NODE_ENV === 'development') { // eslint-disable-next-line no-console console.warn(`[This message will only show in development]: \`set\` for path ${path} has failed.`, e) } } }) return { data } }) } this.getInput = (path) => { try { return this.getData()[path] as DataInput } catch (e) { console.warn(TweakErrors.PATH_DOESNT_EXIST, path, "null") } } this.get = (path) => { return this.getInput(path)?.value } this.emitOnEditStart = (path: string) => { eventEmitter.emit(`onEditStart:${path}`, this.get(path), path, { ...this.getInput(path), get: this.get }) } this.emitOnEditEnd = (path: string) => { eventEmitter.emit(`onEditEnd:${path}`, this.get(path), path, { ...this.getInput(path), get: this.get }) } this.subscribeToEditStart = (path: string, listener: (value: any) => void): (() => void) => { const _path = `onEditStart:${path}` eventEmitter.on(_path, listener) return () => eventEmitter.off(_path, listener) } this.subscribeToEditEnd = (path: string, listener: (value: any) => void): (() => void) => { const _path = `onEditEnd:${path}` eventEmitter.on(_path, listener) return () => eventEmitter.off(_path, listener) } /** * Recursively extract the data from the schema, sets folder initial * preferences and normalize the inputs (normalizing an input means parsing the * input object, identify its type and normalize its settings). * * @param schema * @param rootPath used for recursivity */ const _getDataFromSchema = (schema: any, rootPath: string, mappedPaths: MappedPaths): Data => { const data: Data = {} Object.entries(schema).forEach(([key, rawInput]: [string, any]) => { // if the key is empty, skip schema parsing and prompt an error. if (key === '') return console.warn(TweakErrors.EMPTY_KEY, "", "") let newPath = join(rootPath, key) // If the input is a folder, then we recursively parse its schema and assign // it to the current data. if (rawInput.type === SpecialInputs.FOLDER) { const newData = _getDataFromSchema(rawInput.schema, newPath, mappedPaths) Object.assign(data, newData) // Sets folder preferences if it wasn't set before if (!(newPath in folders)) folders[newPath] = rawInput.settings as FolderSettings } else if (key in mappedPaths) { // if a key already exists, prompt an error. console.warn(TweakErrors.DUPLICATE_KEYS, key, newPath) } else { const normalizedInput = normalizeInput(rawInput, key, newPath, data) if (normalizedInput) { const { type, options, input } = normalizedInput // @ts-ignore const { onChange, transient, onEditStart, onEditEnd, ..._options } = options data[newPath] = { type, ..._options, ...input, fromPanel: true } mappedPaths[key] = { path: newPath, onChange, transient, onEditStart, onEditEnd } } else { console.warn(TweakErrors.UNKNOWN_INPUT, newPath, rawInput) } } }) return data } this.getDataFromSchema = (schema) => { const mappedPaths: MappedPaths = {} const data = _getDataFromSchema(schema, '', mappedPaths) return [data, mappedPaths] } } as any as { new(): StoreType } export const tweakStore = new Store() export function useCreateStore() { return useMemo(() => new Store(), []) } if (process.env.NODE_ENV === 'development' && typeof window !== 'undefined') { // TODO remove store from window // @ts-expect-error window.__STORE = tweakStore }