From 9bfd4b266497917706c4ee2e5f446ad663b116f5 Mon Sep 17 00:00:00 2001 From: Palash Bansal Date: Mon, 29 May 2023 01:06:13 +0530 Subject: [PATCH] Proper changeEvent, handle arbitrary object and array types with decorators. --- .idea/uiconfig.js.iml | 1 + README.md | 8 ++++-- package-lock.json | 15 ++++++---- package.json | 6 ++-- src/UiConfigMethods.ts | 45 ++++++++++++++++++------------ src/UiConfigRendererBase.ts | 3 +- src/decorator_alias.ts | 20 +++++++------- src/decorator_utils.ts | 55 +++++++++++++++++++++++++++++++++++-- src/decorators.ts | 30 ++++++++++++-------- src/index.ts | 12 ++++++-- src/types.ts | 22 ++++++++++++--- tsconfig.json | 2 +- 12 files changed, 159 insertions(+), 60 deletions(-) diff --git a/.idea/uiconfig.js.iml b/.idea/uiconfig.js.iml index 0ae9ac5..5fa8863 100644 --- a/.idea/uiconfig.js.iml +++ b/.idea/uiconfig.js.iml @@ -7,6 +7,7 @@ + diff --git a/README.md b/README.md index 68aeafc..38fedd3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # UiConfig +[![https://nodei.co/npm/uiconfig.js.png?downloads=true&downloadRank=true&stars=true](https://nodei.co/npm/uiconfig.js.png?downloads=true&downloadRank=true&stars=true)](https://www.npmjs.com/package/uiconfig.js) + +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](./LICENSE) + A super small UI renderer framework to dynamically generate website/configuration UIs from a JSON-like configurations and/or typescript decorators. It includes several themes with components for editor-like user interfaces like panels, sliders, pickers, inputs for string, number, file, vector, colors, etc. @@ -451,7 +455,7 @@ export interface UiObjectConfig * onChange callbacks can be added to the config object to be called when the value of the object changes. * This can be a function or an array of functions. */ - onChange?: ValOrArrOp<((...args: ChangeArgs) => void)>[]; + onChange?: ValOrArrOp<((...args: ChangeArgs) => void)>; /** * A function to be called when the Ui element is clicked. @@ -571,4 +575,4 @@ renderer.appendUiConfig(config); ## Integration with three.js -TODO +See [uiconfig-tweakpane](https://github.com/repalash/uiconfig-tweakpane) diff --git a/package-lock.json b/package-lock.json index b784d07..bcc98d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "uiconfig.js", - "version": "0.0.2", + "version": "0.0.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "uiconfig.js", - "version": "0.0.2", + "version": "0.0.4", "license": "MIT", "devDependencies": { "@rollup/plugin-commonjs": "^24.0.1", @@ -26,7 +26,7 @@ "rollup-plugin-delete": "^2.0.0", "rollup-plugin-license": "^3.0.1", "rollup-plugin-multi-input": "^1.3.3", - "ts-browser-helpers": "^0.2.0", + "ts-browser-helpers": "^0.5.0", "tslib": "^2.5.0", "typedoc": "^0.23.26", "typescript": "^4.9.5", @@ -4807,9 +4807,10 @@ } }, "node_modules/ts-browser-helpers": { - "version": "0.2.0", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ts-browser-helpers/-/ts-browser-helpers-0.5.0.tgz", + "integrity": "sha512-seKCLyEIzNfjaVSYMMhYvvQlj0OHfgHbtbeOJNrFufhalmQOJcj5NP/PSMCcIU1qrKgcXNYuAolgtTmsPG6Aaw==", "dev": true, - "license": "MIT", "dependencies": { "@types/wicg-file-system-access": "^2020.9.5" } @@ -8172,7 +8173,9 @@ "dev": true }, "ts-browser-helpers": { - "version": "0.2.0", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ts-browser-helpers/-/ts-browser-helpers-0.5.0.tgz", + "integrity": "sha512-seKCLyEIzNfjaVSYMMhYvvQlj0OHfgHbtbeOJNrFufhalmQOJcj5NP/PSMCcIU1qrKgcXNYuAolgtTmsPG6Aaw==", "dev": true, "requires": { "@types/wicg-file-system-access": "^2020.9.5" diff --git a/package.json b/package.json index eee007d..7f40cd5 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name": "uiconfig.js", - "version": "0.0.2", + "version": "0.0.4", "description": "A framework for building user interface layouts with JSON configuration.", "main": "src/index.ts", - "module": "src/index.ts", + "module": "dist/index.mjs", "types": "src/index.ts", "source": "src/index.ts", "sideEffects": false, @@ -77,7 +77,7 @@ "rollup-plugin-delete": "^2.0.0", "rollup-plugin-license": "^3.0.1", "rollup-plugin-multi-input": "^1.3.3", - "ts-browser-helpers": "^0.2.0", + "ts-browser-helpers": "^0.5.0", "tslib": "^2.5.0", "typescript": "^4.9.5", "uuid": "^9.0.0", diff --git a/src/UiConfigMethods.ts b/src/UiConfigMethods.ts index 3a456a9..a81239f 100644 --- a/src/UiConfigMethods.ts +++ b/src/UiConfigMethods.ts @@ -1,4 +1,4 @@ -import {ChangeArgs, UiObjectConfig} from './types' +import {ChangeArgs, ChangeEvent, UiObjectConfig} from './types' import {Fof, getOrCall, safeSetProperty} from 'ts-browser-helpers' import {UiConfigRendererBase} from './UiConfigRendererBase' import {v4} from 'uuid' @@ -21,36 +21,47 @@ export class UiConfigMethods { return tar ? tar[key] : undefined } - dispatchOnChange(config: UiObjectConfig, props: {last?: boolean}) { - const changeEvent = { - // todo; change event + dispatchOnChangeSync(config: UiObjectConfig, props: {last?: boolean, config?: UiObjectConfig, configPath?: UiObjectConfig[]}, ...args: any[]) { + const changeEvent: ChangeEvent = { + type: 'change', last: props.last ?? true, + config: props.config ?? config, + configPath: [config, ...props.configPath || []], + target: config, } - const changeArgs: ChangeArgs = [changeEvent] - ;[config.onChange].flat().forEach((c) => - typeof c === 'function' && c?.(...changeArgs), - ) - // console.log(value, changeEvent.last) - // if (typeof tar?.setDirty === 'function') tar.setDirty(changeEvent) - // todo: dispatch global change event? for setDirty etc + const changeArgs: ChangeArgs = [changeEvent, ...args] + if (typeof config.onChange === 'function') config.onChange(...changeArgs) + else if (Array.isArray(config.onChange)) { + config.onChange.flat().forEach((c) => + typeof c === 'function' && c?.(...changeArgs), // todo .call with config if not a bound function + ) + } else if (config.onChange) { + console.error('Invalid onChange type, must be a function or array of functions', config.onChange) + } + config.parentOnChange?.(...changeArgs) } - async setValue(config: UiObjectConfig, value: T, props: {last?: boolean}) { + async setValue(config: UiObjectConfig, value: T, props: {last?: boolean, config?: UiObjectConfig, configPath?: UiObjectConfig[]}, forceOnChange?: boolean) { return this.runAtEvent(config, () => { const [tar, key] = this.getBinding(config) if (!tar || value === tar[key] || !safeSetProperty(tar, key, value, true, true)) { - return false + if(!forceOnChange) return false } - this.dispatchOnChange(config, props) + this.dispatchOnChangeSync(config, props) return true }) } + async dispatchOnChange(config: UiObjectConfig, props: {last?: boolean, config?: UiObjectConfig, configPath?: UiObjectConfig[]}) { + return this.runAtEvent(config, () => { + this.dispatchOnChangeSync(config, props) + }) + } - getLabel(config: UiObjectConfig) { - return getOrCall(config.label) ?? this.getBinding(config)[1] + getLabel(config: UiObjectConfig): string { + return (getOrCall(config.label) ?? this.getBinding(config)[1]) + '' } - getChildren(config: UiObjectConfig) { + getChildren(config: UiObjectConfig): UiObjectConfig[] { return (config.children ?? []).map(v => getOrCall(v)).flat(2).filter(v => v) as UiObjectConfig[] } diff --git a/src/UiConfigRendererBase.ts b/src/UiConfigRendererBase.ts index 779a629..92551c3 100644 --- a/src/UiConfigRendererBase.ts +++ b/src/UiConfigRendererBase.ts @@ -87,7 +87,8 @@ export abstract class UiConfigRendererBase extends SimpleEventDis // this._renderUiConfig(uiConfig) // } - appendChild(config: UiObjectConfig) { + appendChild(config?: UiObjectConfig) { + if (!config) return this.config.children!.push(config) this.refreshRoot() } diff --git a/src/decorator_alias.ts b/src/decorator_alias.ts index ef1cf4a..85728f3 100644 --- a/src/decorator_alias.ts +++ b/src/decorator_alias.ts @@ -1,43 +1,43 @@ import {UiObjectConfig} from './types' import {TParams, uiConfig, uiContainer} from './decorators' -export function uiMonitor(label?: string, params?: TParams): PropertyDecorator { +export function uiMonitor(label?: string, params?: TParams): PropertyDecorator { return uiConfig('monitor', {label, params}) } -export function uiSlider(label?: string, bounds?: [number, number], stepSize?: number, params?: TParams): PropertyDecorator { +export function uiSlider(label?: string, bounds?: [number, number], stepSize?: number, params?: TParams): PropertyDecorator { return uiConfig('slider', {label, bounds, stepSize, params}) } -export function uiVector(label?: string, bounds?: [number, number], stepSize?: number, params?: TParams): PropertyDecorator { +export function uiVector(label?: string, bounds?: [number, number], stepSize?: number, params?: TParams): PropertyDecorator { return uiConfig('vec', {label, bounds, stepSize, params}) } -export function uiDropdown(label?: string, children?: UiObjectConfig[], params?: TParams): PropertyDecorator { +export function uiDropdown(label?: string, children?: UiObjectConfig[], params?: TParams): PropertyDecorator { return uiConfig('dropdown', {label, children, params}) } -export function uiButton(label?: string, params?: TParams): PropertyDecorator { +export function uiButton(label?: string, params?: TParams): PropertyDecorator { return uiConfig('button', {label, params}) } -export function uiInput(label?: string, params?: TParams): PropertyDecorator { +export function uiInput(label?: string, params?: TParams): PropertyDecorator { return uiConfig('input', {label, params}) } -export function uiNumber(label?: string, params?: TParams): PropertyDecorator { +export function uiNumber(label?: string, params?: TParams): PropertyDecorator { return uiConfig('number', {label, params}) } -export function uiColor(label?: string, params?: TParams): PropertyDecorator { +export function uiColor(label?: string, params?: TParams): PropertyDecorator { return uiConfig('color', {label, params}) } -export function uiImage(label?: string, params?: TParams): PropertyDecorator { +export function uiImage(label?: string, params?: TParams): PropertyDecorator { return uiConfig('image', {label, params}) } -export function uiToggle(label?: string, params?: TParams): PropertyDecorator { +export function uiToggle(label?: string, params?: TParams): PropertyDecorator { return uiConfig('checkbox', {label, params}) } diff --git a/src/decorator_utils.ts b/src/decorator_utils.ts index 3ff4b41..0614612 100644 --- a/src/decorator_utils.ts +++ b/src/decorator_utils.ts @@ -5,9 +5,32 @@ export class UiConfigTypeMap { static Map = new Map() } +function generateValueConfig(obj: any, key: string | number, label?: string, val?: any) { + val = val ?? obj[key] + const config = val?.uiConfig + let result: UiObjectConfig|undefined = undefined + if (config) { + result = config + } else { + const uiType = valueToUiType(val) + if (uiType === 'folder') { + result = generateUiFolder(key + '', val) + } else if (uiType) + result = { + type: uiType, + label: key + '', + property: [obj, key], + } + } + label = label ?? key + '' + if (result && !result.label) result.label = label + return result +} + export function generateUiConfig(obj: any): UiObjectConfig[] { - let type = obj?.constructor - if (!obj || !type) return [] + if (!obj) return [] + let type = obj.constructor || Object + if (type === Array) type = Object const result: UiObjectConfig[] = [] const types: any[] = [] @@ -15,12 +38,22 @@ export function generateUiConfig(obj: any): UiObjectConfig[] { types.push(type) type = Object.getPrototypeOf(type) } + if (!types.length) { + const keys = typeof obj === 'object' ? Object.keys(obj) : Array.isArray(obj) ? obj.map((_, i)=>i) : [] + for (const key of keys) { + const val = obj[key] + if (val === undefined || val === null) continue + // if (Array.isArray(obj)) debugger + const c = generateValueConfig(obj, key, key + '', val) + if (c) result.push(c) + } + } // reversing so we get the parent first types.reverse().forEach(t => { UiConfigTypeMap.Map.get(t)?.forEach(({params, propKey, uiType}: any) => { let config: any if (!uiType) { - config = obj[propKey]?.uiConfig + config = generateValueConfig(obj, propKey) } if (!config) { config = { @@ -60,3 +93,19 @@ export function generateUiFolder(label: string, obj: any, params: any = {}, type } } + +export function valueToUiType(val: any) { + if (val === null || val === undefined) return null + if (Array.isArray(val)) return 'folder' + if (typeof val === 'boolean') return 'checkbox' + if (typeof val === 'number') return 'number' + if (typeof val === 'string') return 'input' + if (typeof val === 'function') return 'button' + if (typeof val.x === 'number') return 'vec' + if (typeof val.r === 'number') return 'color' + if (val.isTexture) return 'image' + if (typeof val === 'object') return 'folder' + return null +} + + diff --git a/src/decorators.ts b/src/decorators.ts index d0a3b98..ab71f31 100644 --- a/src/decorators.ts +++ b/src/decorators.ts @@ -1,21 +1,30 @@ -import {ValOrFunc} from 'ts-browser-helpers' +import type {ValOrFunc} from 'ts-browser-helpers' import {UiObjectConfig} from './types' import {generateUiFolder, UiConfigTypeMap} from './decorator_utils' -export type TParams = ValOrFunc, [Partial]> +export type TParams = ValOrFunc, [T]> -// for properties -export function uiConfig(uiType?: string, params?: Partial & {params?: TParams}): PropertyDecorator { +/** + * Decorator for uiConfig + * @param action - function that will be called with the targetPrototype, propertyKey and uiConfigs, and should modify the uiConfigs + */ +export function uiConfigDecorator(action: (targetPrototype: any, propertyKey: string|symbol, uiConfigs: any[])=>void): PropertyDecorator { return (targetPrototype: any, propertyKey: string | symbol) => { const type = targetPrototype.constructor - if (type === Object) throw new Error('All properties in an object are serialized by default') + if (type === Object) throw new Error('Not possible to use uiConfig decorator on an object, use class instead') if (!UiConfigTypeMap.Map.has(type)) UiConfigTypeMap.Map.set(type, []) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + action(targetPrototype, propertyKey, UiConfigTypeMap.Map.get(type)!) + } +} - const arr = UiConfigTypeMap.Map.get(type) - const index = arr?.findIndex(item => item.propKey === propertyKey) - if (arr && index && index < 0) - arr.push({ +// for properties +export function uiConfig(uiType?: string, params?: Partial & {params?: TParams, group?: string}): PropertyDecorator { + return uiConfigDecorator((_, propertyKey, uiConfigs) => { + const index = uiConfigs.findIndex(item => item.propKey === propertyKey) + if (index && index < 0) + uiConfigs.push({ params: params || {}, propKey: propertyKey as string, uiType, @@ -23,8 +32,7 @@ export function uiConfig(uiType?: string, params?: Partial & {pa else { throw new Error(`Property ${propertyKey as string} already has a uiConfig decorator`) } - } - + }) } // for classes diff --git a/src/index.ts b/src/index.ts index a3fb808..84fb180 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,13 +14,21 @@ import { uiVector, } from './decorator_alias' import {generateUiConfig, generateUiFolder, UiConfigTypeMap} from './decorator_utils' -import {ChangeArgs, IUiConfigContainer, TUiRefreshModes, UiConfigContainer, UiObjectConfig, UiObjectType} from './types' +import { + ChangeArgs, + ChangeEvent, + IUiConfigContainer, + TUiRefreshModes, + UiConfigContainer, + UiObjectConfig, + UiObjectType, +} from './types' import {UiConfigRendererBase} from './UiConfigRendererBase' import {UiConfigMethods} from './UiConfigMethods' export {UiConfigRendererBase, UiConfigMethods} -export type {UiConfigContainer, IUiConfigContainer, UiObjectConfig, UiObjectType, TUiRefreshModes, ChangeArgs} +export type {UiConfigContainer, IUiConfigContainer, UiObjectConfig, UiObjectType, TUiRefreshModes, ChangeArgs, ChangeEvent} // decorators export {uiConfig, uiContainer} diff --git a/src/types.ts b/src/types.ts index 2183dbd..2a39610 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,15 @@ -import {Fof, StringKeyOf, ValOrArr, ValOrArrOp, ValOrFunc} from 'ts-browser-helpers' +import type {Fof, StringKeyOf, ValOrArr, ValOrArrOp, ValOrFunc} from 'ts-browser-helpers' export type TUiRefreshModes = 'preRender' | 'postRender' | 'preFrame' | 'postFrame' export type UiObjectType = string -export type ChangeArgs = any[] +export interface ChangeEvent { + target?: UiObjectConfig, + type: 'change', + last?: boolean, // true if this is the last change event in a chain of changes + config?: UiObjectConfig, // the config that triggered the change + configPath?: UiObjectConfig[], // list of all configs from target to the one that triggered the change +} +export type ChangeArgs = [ChangeEvent, ...any[]] | never[] export interface UiObjectConfig { /** @@ -30,8 +37,9 @@ export interface UiObjectConfig]>, + property?: ValOrFunc<[TTarget, StringKeyOf|number]>, /** * Alias for property */ @@ -80,7 +88,7 @@ export interface UiObjectConfig void)>[]; + onChange?: ValOrArrOp<((...args: ChangeArgs) => void)>; /** * A function to be called when the Ui element is clicked. @@ -148,6 +156,12 @@ export interface UiObjectConfig void; + /** * Individual components can support custom options. These can be added to the config object. */ diff --git a/tsconfig.json b/tsconfig.json index 351af10..06f98ae 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,7 @@ "noImplicitThis": true, "noUnusedLocals": true, "noUnusedParameters": true, - "removeComments": true, + "removeComments": false, "preserveConstEnums": true, "moduleResolution": "node", "emitDecoratorMetadata": true,