/** * This is responsible for creating the controls pane in the UI. */ import LocalStoreContext from '../runtime/ui/LocalStoreContext' export interface ControlVariableConfig { label: string name: string default: LocalStoreValue tooltip?: string validation?: (value: LocalStoreValue) => {isValid: boolean, message: string} options?: { label: string value: LocalStoreValue }[] } export interface ControlVariable extends ControlVariableConfig { type: 'number'|'string'|'boolean' } type LocalStoreValue = string | number | boolean export interface ControlsConstructor { root: HTMLDivElement settings: ControlVariableConfig[] id?: string title?: string } export default class Controls { private context: LocalStoreContext private settings: ControlVariable[] = [] constructor(opts: ControlsConstructor) { const { root, settings, title = 'Controls', id = '_controls_' } = opts this.context = new LocalStoreContext(id) // infer a type for each setting settings.forEach( setting => { if (typeof setting.default === 'string') { // setting.type = 'string' this.settings.push({type: 'string', ...setting}) } else if (typeof setting.default === 'number') { // setting.type = 'number' this.settings.push({type: 'number', ...setting}) } else if (typeof setting.default === 'boolean') { // setting.type = 'boolean' this.settings.push({type: 'boolean', ...setting}) } else { throw new Error(`Unknown type for setting ${setting.name}`) } }) if (this.context.length() === 0) { this.settings.forEach( setting => { this.context.set(setting.name, setting.default, setting.type) }) } const controlsPane = document.createElement('drag-pane') controlsPane.setAttribute('heading', title) controlsPane.setAttribute('key', 'controls') const controlsContainer = document.createElement('div') controlsContainer.style.display = 'flex' controlsContainer.style.flexDirection = 'column' controlsContainer.style.alignItems = 'flex-start' controlsContainer.style.padding = '5px' controlsContainer.style.width = 300 + 'px' const resetButton = document.createElement('button') resetButton.innerText = 'Reset Values' resetButton.style.margin = '5px' resetButton.addEventListener('click', () => { this.reset() window.location.reload() }) this.settings.forEach(setting => { const control = setting?.options && setting.options.length > 0 ? generateSelection(setting, this.context) : generateInput(setting, this.context) controlsContainer.appendChild(control) }) controlsContainer.appendChild(resetButton) controlsPane.appendChild(controlsContainer) root.appendChild(controlsPane) } public getSetting(name: string) { return this.context.get(name) } public setSetting(name: string, value: LocalStoreValue, type: ControlVariable['type']): void { this.context.set(name, value, type) } public reset(): void { this.context.clear() this.settings.forEach( setting => { this.context.set(setting.name, setting.default, setting.type) }) } } // generates select & option elements based on the options of the setting const generateSelection = (setting: ControlVariable, context: LocalStoreContext): HTMLDivElement => { // create a div to hold the input and label const wrapper = document.createElement('div') const select = document.createElement('select') select.style.margin = '1px 3px' select.style.width = '200px' setting.options?.forEach( option => { const optionElement = document.createElement('option') optionElement.value = option.value as string optionElement.innerText = option.label as string select.appendChild(optionElement) }) select.value = context.get(setting.name) as string select.id = setting.name const label = document.createElement('label') label.htmlFor = setting.name label.innerText = setting.label label.title = setting.tooltip || '' // underline the label if it has a tooltip if (setting.tooltip) { label.style.cursor = 'help' } wrapper.appendChild(label) wrapper.appendChild(select) wrapper.querySelector('select')!.addEventListener('input', (event) => { context.set(setting.name, (event.target as HTMLSelectElement).value, setting.type) }) return wrapper } // generates an input element based on the type of the setting const generateInput = (setting: ControlVariable, context: LocalStoreContext): HTMLDivElement => { // create a div to hold the input and label const wrapper = document.createElement('div') const input = document.createElement('input') input.style.margin = '1px 3px' input.style.width = '50px' // counts the number of decimal places in a float const numericalPrecision = typeof context.get(setting.name) === 'number' ? ((context.get(setting.name) as number).toString().split('.')[1] ?? []).length : null switch(setting.type) { case 'number': input.type = 'number' input.value = context.get(setting.name) as string if (numericalPrecision) { input.step = '0.' + '0'.repeat(numericalPrecision - 1) + '1' } else { input.step = '1' } break case 'string': input.type = 'text' input.value = context.get(setting.name) as string break case 'boolean': input.type = 'checkbox' input.checked = context.get(setting.name) as boolean break } input.id = setting.name const label = document.createElement('label') label.htmlFor = setting.name label.innerText = setting.label label.title = setting.tooltip || '' // underline the label if it has a tooltip if (setting.tooltip) { label.style.cursor = 'help' } wrapper.appendChild(label) wrapper.appendChild(input) wrapper.querySelector('input')!.addEventListener('input', (event) => { let newValue: LocalStoreValue let oldValue = context.get(setting.name) if ( setting.type === 'boolean' ) { newValue = (event.target as HTMLInputElement).checked } else { newValue = (event.target as HTMLInputElement).value } if (setting.validation) { const {isValid = true, message = ''} = setting.validation(newValue) ?? {} if (!isValid) { console.error(`Invalid value for ${setting.name}: ${message}`) context.set(setting.name, oldValue, setting.type) ;(event.target as HTMLInputElement).value = oldValue as string } else { context.set(setting.name, newValue, setting.type) } } else { context.set(setting.name, newValue, setting.type) } }) return wrapper }