Skip to content

Commit

Permalink
Proper changeEvent, handle arbitrary object and array types with deco…
Browse files Browse the repository at this point in the history
…rators.
  • Loading branch information
repalash committed May 28, 2023
1 parent 9fcdad5 commit 9bfd4b2
Show file tree
Hide file tree
Showing 12 changed files with 159 additions and 60 deletions.
1 change: 1 addition & 0 deletions .idea/uiconfig.js.iml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -451,7 +455,7 @@ export interface UiObjectConfig<T=any, TType extends UiObjectType=UiObjectType>
* 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.
Expand Down Expand Up @@ -571,4 +575,4 @@ renderer.appendUiConfig(config);

## Integration with three.js

TODO
See [uiconfig-tweakpane](https://github.com/repalash/uiconfig-tweakpane)
15 changes: 9 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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",
Expand Down
45 changes: 28 additions & 17 deletions src/UiConfigMethods.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<T>(config: UiObjectConfig<T>, value: T, props: {last?: boolean}) {
async setValue<T>(config: UiObjectConfig<T>, 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<T>(config: UiObjectConfig<T>, 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[]
}

Expand Down
3 changes: 2 additions & 1 deletion src/UiConfigRendererBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ export abstract class UiConfigRendererBase<TUiNode = any> extends SimpleEventDis
// this._renderUiConfig(uiConfig)
// }

appendChild(config: UiObjectConfig) {
appendChild(config?: UiObjectConfig) {
if (!config) return
this.config.children!.push(config)
this.refreshRoot()
}
Expand Down
20 changes: 10 additions & 10 deletions src/decorator_alias.ts
Original file line number Diff line number Diff line change
@@ -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<T=any>(label?: string, params?: TParams<T>): PropertyDecorator {
return uiConfig('monitor', {label, params})
}

export function uiSlider(label?: string, bounds?: [number, number], stepSize?: number, params?: TParams): PropertyDecorator {
export function uiSlider<T=any>(label?: string, bounds?: [number, number], stepSize?: number, params?: TParams<T>): PropertyDecorator {
return uiConfig('slider', {label, bounds, stepSize, params})
}

export function uiVector(label?: string, bounds?: [number, number], stepSize?: number, params?: TParams): PropertyDecorator {
export function uiVector<T=any>(label?: string, bounds?: [number, number], stepSize?: number, params?: TParams<T>): PropertyDecorator {
return uiConfig('vec', {label, bounds, stepSize, params})
}

export function uiDropdown(label?: string, children?: UiObjectConfig[], params?: TParams): PropertyDecorator {
export function uiDropdown<T=any>(label?: string, children?: UiObjectConfig[], params?: TParams<T>): PropertyDecorator {
return uiConfig('dropdown', {label, children, params})
}

export function uiButton(label?: string, params?: TParams): PropertyDecorator {
export function uiButton<T=any>(label?: string, params?: TParams<T>): PropertyDecorator {
return uiConfig('button', {label, params})
}

export function uiInput(label?: string, params?: TParams): PropertyDecorator {
export function uiInput<T=any>(label?: string, params?: TParams<T>): PropertyDecorator {
return uiConfig('input', {label, params})
}

export function uiNumber(label?: string, params?: TParams): PropertyDecorator {
export function uiNumber<T=any>(label?: string, params?: TParams<T>): PropertyDecorator {
return uiConfig('number', {label, params})
}

export function uiColor(label?: string, params?: TParams): PropertyDecorator {
export function uiColor<T=any>(label?: string, params?: TParams<T>): PropertyDecorator {
return uiConfig('color', {label, params})
}

export function uiImage(label?: string, params?: TParams): PropertyDecorator {
export function uiImage<T=any>(label?: string, params?: TParams<T>): PropertyDecorator {
return uiConfig('image', {label, params})
}

export function uiToggle(label?: string, params?: TParams): PropertyDecorator {
export function uiToggle<T=any>(label?: string, params?: TParams<T>): PropertyDecorator {
return uiConfig('checkbox', {label, params})
}

Expand Down
55 changes: 52 additions & 3 deletions src/decorator_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,55 @@ export class UiConfigTypeMap {
static Map = new Map<ObjectConstructor, any[]>()
}

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[] = []
while (type && type !== Object) {
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 = {
Expand Down Expand Up @@ -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
}


30 changes: 19 additions & 11 deletions src/decorators.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,38 @@
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<UiObjectConfig>, [Partial<UiObjectConfig>]>
export type TParams<T> = ValOrFunc<Partial<UiObjectConfig>, [T]>

// for properties
export function uiConfig(uiType?: string, params?: Partial<UiObjectConfig> & {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<T=any>(uiType?: string, params?: Partial<UiObjectConfig> & {params?: TParams<T>, 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,
})
else {
throw new Error(`Property ${propertyKey as string} already has a uiConfig decorator`)
}
}

})
}

// for classes
Expand Down
12 changes: 10 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Loading

0 comments on commit 9bfd4b2

Please sign in to comment.