/** * Copyright (c) 2018-2021 mol* contributors, licensed under MIT, See LICENSE file for more info. * * @author David Sehnal * @author Alexander Rose */ import * as React from 'react'; import { Mat4, Vec2, Vec3 } from '../../mol-math/linear-algebra'; import { Script } from '../../mol-script/script'; import { Asset } from '../../mol-util/assets'; import { Color } from '../../mol-util/color'; import { ColorListEntry } from '../../mol-util/color/color'; import { ColorListName, ColorListOptions, ColorListOptionsScale, ColorListOptionsSet, getColorListFromName } from '../../mol-util/color/lists'; import { Legend as LegendData } from '../../mol-util/legend'; import { memoize1, memoizeLatest } from '../../mol-util/memoize'; import { getPrecision } from '../../mol-util/number'; import { ParamDefinition as PD } from '../../mol-util/param-definition'; import { ParamMapping } from '../../mol-util/param-mapping'; import { camelCaseToWords } from '../../mol-util/string'; import { PluginUIComponent } from '../base'; import { PluginUIContext } from '../context'; import { ActionMenu } from './action-menu'; import { ColorOptions, ColorValueOption, CombinedColorControl } from './color'; import { Button, ControlGroup, ControlRow, ExpandGroup, IconButton, TextInput, ToggleButton } from './common'; import { ArrowDownwardSvg, ArrowDropDownSvg, ArrowRightSvg, ArrowUpwardSvg, BookmarksOutlinedSvg, CheckSvg, ClearSvg, DeleteOutlinedSvg, HelpOutlineSvg, Icon, MoreHorizSvg } from './icons'; import { legendFor } from './legend'; import { LineGraphComponent } from './line-graph/line-graph-component'; import { Slider, Slider2 } from './slider'; export type ParameterControlsCategoryFilter = string | null | (string | null)[] export interface ParameterControlsProps

{ params: P, values: any, onChange?: ParamsOnChange>, onChangeValues?: (values: PD.ValuesFor

, prev: PD.ValuesFor

) => void, isDisabled?: boolean, onEnter?: () => void } export class ParameterControls

extends React.PureComponent> { onChange: ParamOnChange = (params) => { this.props.onChange?.(params, this.props.values); if (this.props.onChangeValues) { const values = { ...this.props.values, [params.name]: params.value }; this.props.onChangeValues(values, this.props.values); } }; renderGroup(group: ParamInfo[]) { if (group.length === 0) return null; const values = this.props.values; let ctrls: JSX.Element[] | null = null; let category: string | undefined = void 0; for (const [key, p, Control] of group) { if (p.hideIf?.(values)) continue; if (!ctrls) ctrls = []; category = p.category; ctrls.push(); } if (!ctrls) return null; if (category) { return [{ctrls}]; } return ctrls; } renderPart(groups: ParamInfo[][]) { let parts: JSX.Element[] | null = null; for (const g of groups) { const ctrls = this.renderGroup(g); if (!ctrls) continue; if (!parts) parts = []; for (const c of ctrls) parts.push(c); } return parts; } paramGroups = memoizeLatest((params: PD.Params) => classifyParams(params)); render() { const groups = this.paramGroups(this.props.params); const essentials = this.renderPart(groups.essentials); const advanced = this.renderPart(groups.advanced); if (essentials && advanced) { return <> {essentials} {advanced} ; } else if (essentials) { return essentials; } else { return advanced; } } } export class ParameterMappingControl extends PluginUIComponent<{ mapping: ParamMapping }> { setSettings = (p: { param: PD.Base, name: string, value: any }, old: any) => { const values = { ...old, [p.name]: p.value }; const t = this.props.mapping.update(values, this.plugin); this.props.mapping.apply(t, this.plugin); }; componentDidMount() { this.subscribe(this.plugin.events.canvas3d.settingsUpdated, () => this.forceUpdate()); } render() { const t = this.props.mapping.getTarget(this.plugin); const values = this.props.mapping.getValues(t, this.plugin); const params = this.props.mapping.params(this.plugin) as any as PD.Params; return ; } } type ParamInfo = [string, PD.Any, ParamControl]; function classifyParams(params: PD.Params) { function addParam(k: string, p: PD.Any, group: typeof essentials) { const ctrl = controlFor(p); if (!ctrl) return; if (!p.category) group.params[0].push([k, p, ctrl]); else { if (!group.map) group.map = new Map(); let c = group.map.get(p.category); if (!c) { c = []; group.map.set(p.category, c); group.params.push(c); } c.push([k, p, ctrl]); } } function sortGroups(x: ParamInfo[], y: ParamInfo[]) { const a = x[0], b = y[0]; if (!a || !a[1].category) return -1; if (!b || !b[1].category) return 1; return a[1].category < b[1].category ? -1 : 1; } const keys = Object.keys(params); const essentials: { params: ParamInfo[][], map: Map | undefined } = { params: [[]], map: void 0 }; const advanced: typeof essentials = { params: [[]], map: void 0 }; for (const k of keys) { const p = params[k]; if (p.isHidden) continue; if (p.isEssential) addParam(k, p, essentials); else addParam(k, p, advanced); } essentials.params.sort(sortGroups); advanced.params.sort(sortGroups); return { essentials: essentials.params, advanced: advanced.params }; } function controlFor(param: PD.Any): ParamControl | undefined { switch (param.type) { case 'value': return void 0; case 'boolean': return BoolControl; case 'number': return typeof param.min !== 'undefined' && typeof param.max !== 'undefined' ? NumberRangeControl : NumberInputControl; case 'converted': return ConvertedControl; case 'conditioned': return ConditionedControl; case 'multi-select': return MultiSelectControl; case 'color': return CombinedColorControl; case 'color-list': return param.offsets ? OffsetColorListControl : ColorListControl; case 'vec3': return Vec3Control; case 'mat4': return Mat4Control; case 'url': return UrlControl; case 'file': return FileControl; case 'file-list': return FileListControl; case 'select': return SelectControl; case 'value-ref': return ValueRefControl; case 'data-ref': return void 0; case 'text': return TextControl; case 'interval': return typeof param.min !== 'undefined' && typeof param.max !== 'undefined' ? BoundedIntervalControl : IntervalControl; case 'group': return GroupControl; case 'mapped': return MappedControl; case 'line-graph': return LineGraphControl; case 'script': return ScriptControl; case 'object-list': return ObjectListControl; default: const _: never = param; console.warn(`${_} has no associated UI component`); return void 0; } } export class ParamHelp extends React.PureComponent<{ legend?: L, description?: string }> { render() { const { legend, description } = this.props; const Legend = legend && legendFor(legend); return

{description}
{Legend &&
}
; } } export type ParamsOnChange

= (params: { param: PD.Base, name: string, value: any }, values: Readonly

) => void export type ParamOnChange = (params: { param: PD.Base, name: string, value: any }) => void export interface ParamProps

= PD.Base> { name: string, value: P['defaultValue'], param: P, isDisabled?: boolean, onChange: ParamOnChange, onEnter?: () => void } export type ParamControl = React.ComponentClass> function renderSimple(options: { props: ParamProps, state: { showHelp: boolean }, control: JSX.Element, addOn: JSX.Element | null, toggleHelp: () => void }) { const { props, state, control, toggleHelp, addOn } = options; const _className = []; if (props.param.shortLabel) _className.push('msp-control-label-short'); if (props.param.twoColumns) _className.push('msp-control-col-2'); const className = _className.join(' '); const label = props.param.label || camelCaseToWords(props.name); const help = props.param.help ? props.param.help(props.value) : { description: props.param.description, legend: props.param.legend }; const hasHelp = help.description || help.legend; const desc = label + (hasHelp ? '. Click for help.' : ''); return <> {label} {hasHelp && } } control={control} /> {hasHelp && state.showHelp &&

} {addOn} ; } export abstract class SimpleParam

extends React.PureComponent, { showHelp: boolean }> { state = { showHelp: false }; protected update(value: P['defaultValue']) { this.props.onChange({ param: this.props.param, name: this.props.name, value }); } abstract renderControl(): JSX.Element; renderAddOn(): JSX.Element | null { return null; } toggleHelp = () => this.setState({ showHelp: !this.state.showHelp }); render() { return renderSimple({ props: this.props, state: this.state, control: this.renderControl(), toggleHelp: this.toggleHelp, addOn: this.renderAddOn() }); } } export class BoolControl extends SimpleParam { onClick = (e: React.MouseEvent) => { this.update(!this.props.value); e.currentTarget.blur(); }; renderControl() { return ; } } export class LineGraphControl extends React.PureComponent, { isExpanded: boolean, isOverPoint: boolean, message: string }> { state = { isExpanded: false, isOverPoint: false, message: `${this.props.param.defaultValue.length} points`, }; onHover = (point?: Vec2) => { this.setState({ isOverPoint: !this.state.isOverPoint }); if (point) { this.setState({ message: `(${point[0].toFixed(2)}, ${point[1].toFixed(2)})` }); return; } this.setState({ message: `${this.props.value.length} points` }); }; onDrag = (point: Vec2) => { this.setState({ message: `(${point[0].toFixed(2)}, ${point[1].toFixed(2)})` }); }; onChange = (value: PD.LineGraph['defaultValue']) => { this.props.onChange({ name: this.props.name, param: this.props.param, value: value }); }; toggleExpanded = (e: React.MouseEvent) => { this.setState({ isExpanded: !this.state.isExpanded }); e.currentTarget.blur(); }; render() { const label = this.props.param.label || camelCaseToWords(this.props.name); return <> {`${this.state.message}`}} />

; } } export class NumberInputControl extends React.PureComponent> { state = { value: '0' }; update = (value: number) => { const p = getPrecision(this.props.param.step || 0.01); value = parseFloat(value.toFixed(p)); this.props.onChange({ param: this.props.param, name: this.props.name, value }); }; render() { const placeholder = this.props.param.label || camelCaseToWords(this.props.name); const label = this.props.param.label || camelCaseToWords(this.props.name); const p = getPrecision(this.props.param.step || 0.01); return } />; } } export class NumberRangeControl extends SimpleParam { onChange = (v: number) => { this.update(v); }; renderControl() { const value = typeof this.props.value === 'undefined' ? this.props.param.defaultValue : this.props.value; return ; } } export class TextControl extends SimpleParam { onChange = (e: React.ChangeEvent) => { const value = e.target.value; if (value !== this.props.value) { this.update(value); } }; onKeyPress = (e: React.KeyboardEvent) => { if ((e.keyCode === 13 || e.charCode === 13 || e.key === 'Enter')) { if (this.props.onEnter) this.props.onEnter(); } e.stopPropagation(); }; renderControl() { const placeholder = this.props.param.label || camelCaseToWords(this.props.name); return ; } } export class PureSelectControl extends React.PureComponent> & { title?: string }> { protected update(value: string | number) { this.props.onChange({ param: this.props.param, name: this.props.name, value }); } onChange = (e: React.ChangeEvent) => { if (typeof this.props.param.defaultValue === 'number') { this.update(parseInt(e.target.value, 10)); } else { this.update(e.target.value); } }; render() { const isInvalid = this.props.value !== void 0 && !this.props.param.options.some(e => e[0] === this.props.value); return ; } } export class SelectControl extends React.PureComponent>, { showHelp: boolean, showOptions: boolean }> { state = { showHelp: false, showOptions: false }; onSelect: ActionMenu.OnSelect = item => { if (!item || item.value === this.props.value) { this.setState({ showOptions: false }); } else { this.setState({ showOptions: false }, () => { this.props.onChange({ param: this.props.param, name: this.props.name, value: item.value }); }); } }; toggle = () => this.setState({ showOptions: !this.state.showOptions }); cycle = () => { const { options } = this.props.param; const current = options.findIndex(o => o[0] === this.props.value); const next = current === options.length - 1 ? 0 : current + 1; this.props.onChange({ param: this.props.param, name: this.props.name, value: options[next][0] }); }; items = memoizeLatest((param: PD.Select) => ActionMenu.createItemsFromSelectOptions(param.options)); renderControl() { const items = this.items(this.props.param); const current = this.props.value !== undefined ? ActionMenu.findItem(items, this.props.value) : void 0; const label = current ? current.label : typeof this.props.value === 'undefined' ? `${ActionMenu.getFirstItem(items)?.label || ''} [Default]` : `[Invalid] ${this.props.value}`; const toggle = this.props.param.cycle ? this.cycle : this.toggle; const textAlign = this.props.param.cycle ? 'center' : 'left'; const icon = this.props.param.cycle ? (this.props.value === 'on' ? CheckSvg : this.props.value === 'off' ? ClearSvg : void 0) : void 0; return ; } renderAddOn() { if (!this.state.showOptions) return null; const items = this.items(this.props.param); const current = ActionMenu.findItem(items, this.props.value); return ; } toggleHelp = () => this.setState({ showHelp: !this.state.showHelp }); render() { return renderSimple({ props: this.props, state: this.state, control: this.renderControl(), toggleHelp: this.toggleHelp, addOn: this.renderAddOn() }); } } export class ValueRefControl extends React.PureComponent>, { showHelp: boolean, showOptions: boolean }> { state = { showHelp: false, showOptions: false }; onSelect: ActionMenu.OnSelect = item => { if (!item || item.value === this.props.value) { this.setState({ showOptions: false }); } else { this.setState({ showOptions: false }, () => { this.props.onChange({ param: this.props.param, name: this.props.name, value: { ref: item.value } }); }); } }; toggle = () => this.setState({ showOptions: !this.state.showOptions }); items = memoizeLatest((param: PD.ValueRef) => ActionMenu.createItemsFromSelectOptions(param.getOptions())); renderControl() { const items = this.items(this.props.param); const current = this.props.value.ref ? ActionMenu.findItem(items, this.props.value.ref) : void 0; const label = current ? current.label : `[Ref] ${this.props.value.ref ?? ''}`; return ; } renderAddOn() { if (!this.state.showOptions) return null; const items = this.items(this.props.param); const current = ActionMenu.findItem(items, this.props.value.ref); return ; } toggleHelp = () => this.setState({ showHelp: !this.state.showHelp }); render() { return renderSimple({ props: this.props, state: this.state, control: this.renderControl(), toggleHelp: this.toggleHelp, addOn: this.renderAddOn() }); } } export class IntervalControl extends React.PureComponent, { isExpanded: boolean }> { state = { isExpanded: false }; components = { 0: PD.Numeric(0, { step: this.props.param.step }, { label: 'Min' }), 1: PD.Numeric(0, { step: this.props.param.step }, { label: 'Max' }) }; change(value: PD.MultiSelect['defaultValue']) { this.props.onChange({ name: this.props.name, param: this.props.param, value }); } componentChange: ParamOnChange = ({ name, value }) => { const v = [...this.props.value]; v[+name] = value; this.change(v); }; toggleExpanded = (e: React.MouseEvent) => { this.setState({ isExpanded: !this.state.isExpanded }); e.currentTarget.blur(); }; render() { const v = this.props.value; const label = this.props.param.label || camelCaseToWords(this.props.name); const p = getPrecision(this.props.param.step || 0.01); const value = `[${v[0].toFixed(p)}, ${v[1].toFixed(p)}]`; return <> {value}} /> {this.state.isExpanded &&
} ; } } export class BoundedIntervalControl extends SimpleParam { onChange = (v: [number, number]) => { this.update(v); }; renderControl() { return ; } } export class ColorControl extends SimpleParam { onChange = (e: React.ChangeEvent) => { this.update(Color(parseInt(e.target.value))); }; stripStyle(): React.CSSProperties { return { background: Color.toStyle(this.props.value), position: 'absolute', bottom: '0', height: '4px', right: '0', left: '0' }; } renderControl() { return
; } } function colorEntryToStyle(e: ColorListEntry, includeOffset = false) { if (Array.isArray(e)) { if (includeOffset) return `${Color.toStyle(e[0])} ${(100 * e[1]).toFixed(2)}%`; return Color.toStyle(e[0]); } return Color.toStyle(e); } const colorGradientInterpolated = memoize1((colors: ColorListEntry[]) => { const styles = colors.map(c => colorEntryToStyle(c, true)); return `linear-gradient(to right, ${styles.join(', ')})`; }); const colorGradientBanded = memoize1((colors: ColorListEntry[]) => { const n = colors.length; const styles: string[] = [`${colorEntryToStyle(colors[0])} ${100 * (1 / n)}%`]; // TODO: does this need to support offsets? for (let i = 1, il = n - 1; i < il; ++i) { styles.push( `${colorEntryToStyle(colors[i])} ${100 * (i / n)}%`, `${colorEntryToStyle(colors[i])} ${100 * ((i + 1) / n)}%` ); } styles.push(`${colorEntryToStyle(colors[n - 1])} ${100 * ((n - 1) / n)}%`); return `linear-gradient(to right, ${styles.join(', ')})`; }); function colorStripStyle(list: PD.ColorList['defaultValue'], right = '0'): React.CSSProperties { return { background: colorGradient(list.colors, list.kind === 'set'), position: 'absolute', bottom: '0', height: '4px', right, left: '0' }; } function colorGradient(colors: ColorListEntry[], banded: boolean) { return banded ? colorGradientBanded(colors) : colorGradientInterpolated(colors); } function createColorListHelpers() { const addOn = (l: [ColorListName, any, any]) => { const preset = getColorListFromName(l[0]); return
; }; return { ColorPresets: { all: ActionMenu.createItemsFromSelectOptions(ColorListOptions, { addOn }), scale: ActionMenu.createItemsFromSelectOptions(ColorListOptionsScale, { addOn }), set: ActionMenu.createItemsFromSelectOptions(ColorListOptionsSet, { addOn }) }, ColorsParam: PD.ObjectList({ color: PD.Color(0x0 as Color) }, ({ color }) => Color.toHexString(color).toUpperCase()), OffsetColorsParam: PD.ObjectList( { color: PD.Color(0x0 as Color), offset: PD.Numeric(0, { min: 0, max: 1, step: 0.01 }) }, ({ color, offset }) => `${Color.toHexString(color).toUpperCase()} [${offset.toFixed(2)}]`), IsInterpolatedParam: PD.Boolean(false, { label: 'Interpolated' }) }; } let _colorListHelpers: ReturnType; function ColorListHelpers() { if (_colorListHelpers) return _colorListHelpers; _colorListHelpers = createColorListHelpers(); return _colorListHelpers; } export class ColorListControl extends React.PureComponent, { showHelp: boolean, show?: 'edit' | 'presets' }> { state = { showHelp: false, show: void 0 as 'edit' | 'presets' | undefined }; protected update(value: PD.ColorList['defaultValue']) { this.props.onChange({ param: this.props.param, name: this.props.name, value }); } toggleEdit = () => this.setState({ show: this.state.show === 'edit' ? void 0 : 'edit' }); togglePresets = () => this.setState({ show: this.state.show === 'presets' ? void 0 : 'presets' }); renderControl() { const { value } = this.props; // TODO: fix the button right offset return <> ; } selectPreset: ActionMenu.OnSelect = item => { if (!item) return; this.setState({ show: void 0 }); const preset = getColorListFromName(item.value as ColorListName); this.update({ kind: preset.type !== 'qualitative' ? 'interpolate' : 'set', colors: preset.list }); }; colorsChanged: ParamOnChange = ({ value }) => { this.update({ kind: this.props.value.kind, colors: (value as (typeof _colorListHelpers)['ColorsParam']['defaultValue']).map(c => c.color) }); }; isInterpolatedChanged: ParamOnChange = ({ value }) => { this.update({ kind: value ? 'interpolate' : 'set', colors: this.props.value.colors }); }; renderColors() { if (!this.state.show) return null; const { ColorPresets, ColorsParam, IsInterpolatedParam } = ColorListHelpers(); const preset = ColorPresets[this.props.param.presetKind]; if (this.state.show === 'presets') return ; const values = this.props.value.colors.map(color => ({ color })); return
; } toggleHelp = () => this.setState({ showHelp: !this.state.showHelp }); render() { return renderSimple({ props: this.props, state: this.state, control: this.renderControl(), toggleHelp: this.toggleHelp, addOn: this.renderColors() }); } } export class OffsetColorListControl extends React.PureComponent, { showHelp: boolean, show?: 'edit' | 'presets' }> { state = { showHelp: false, show: void 0 as 'edit' | 'presets' | undefined }; protected update(value: PD.ColorList['defaultValue']) { this.props.onChange({ param: this.props.param, name: this.props.name, value }); } toggleEdit = () => this.setState({ show: this.state.show === 'edit' ? void 0 : 'edit' }); togglePresets = () => this.setState({ show: this.state.show === 'presets' ? void 0 : 'presets' }); renderControl() { const { value } = this.props; // TODO: fix the button right offset return <> ; } selectPreset: ActionMenu.OnSelect = item => { if (!item) return; this.setState({ show: void 0 }); const preset = getColorListFromName(item.value as ColorListName); this.update({ kind: preset.type !== 'qualitative' ? 'interpolate' : 'set', colors: preset.list }); }; colorsChanged: ParamOnChange = ({ value }) => { const colors = (value as (typeof _colorListHelpers)['OffsetColorsParam']['defaultValue']).map(c => [c.color, c.offset] as [Color, number]); colors.sort((a, b) => a[1] - b[1]); this.update({ kind: this.props.value.kind, colors }); }; isInterpolatedChanged: ParamOnChange = ({ value }) => { this.update({ kind: value ? 'interpolate' : 'set', colors: this.props.value.colors }); }; renderColors() { if (!this.state.show) return null; const { ColorPresets, OffsetColorsParam, IsInterpolatedParam } = ColorListHelpers(); const preset = ColorPresets[this.props.param.presetKind]; if (this.state.show === 'presets') return ; const colors = this.props.value.colors; const values = colors.map((color, i) => { if (Array.isArray(color)) return { color: color[0], offset: color[1] }; return { color, offset: i / colors.length }; }); values.sort((a, b) => a.offset - b.offset); return
; } toggleHelp = () => this.setState({ showHelp: !this.state.showHelp }); render() { return renderSimple({ props: this.props, state: this.state, control: this.renderControl(), toggleHelp: this.toggleHelp, addOn: this.renderColors() }); } } export class Vec3Control extends React.PureComponent, { isExpanded: boolean }> { state = { isExpanded: false }; components = { 0: PD.Numeric(0, { step: this.props.param.step }, { label: (this.props.param.fieldLabels && this.props.param.fieldLabels.x) || 'X' }), 1: PD.Numeric(0, { step: this.props.param.step }, { label: (this.props.param.fieldLabels && this.props.param.fieldLabels.y) || 'Y' }), 2: PD.Numeric(0, { step: this.props.param.step }, { label: (this.props.param.fieldLabels && this.props.param.fieldLabels.z) || 'Z' }) }; change(value: PD.MultiSelect['defaultValue']) { this.props.onChange({ name: this.props.name, param: this.props.param, value }); } componentChange: ParamOnChange = ({ name, value }) => { const v = Vec3.copy(Vec3.zero(), this.props.value); v[+name] = value; this.change(v); }; toggleExpanded = (e: React.MouseEvent) => { this.setState({ isExpanded: !this.state.isExpanded }); e.currentTarget.blur(); }; render() { const v = this.props.value; const label = this.props.param.label || camelCaseToWords(this.props.name); const p = getPrecision(this.props.param.step || 0.01); const value = `[${v[0].toFixed(p)}, ${v[1].toFixed(p)}, ${v[2].toFixed(p)}]`; return <> {value}} /> {this.state.isExpanded &&
} ; } } export class Mat4Control extends React.PureComponent, { isExpanded: boolean }> { state = { isExpanded: false }; components = { json: PD.Text(JSON.stringify(Mat4()), { description: 'JSON array with 4x4 matrix in a column major (j * 4 + i indexing) format' }) }; change(value: PD.MultiSelect['defaultValue']) { this.props.onChange({ name: this.props.name, param: this.props.param, value }); } componentChange: ParamOnChange = ({ name, value }) => { const v = Mat4.copy(Mat4(), this.props.value); if (name === 'json') { Mat4.copy(v, JSON.parse(value)); } else { v[+name] = value; } this.change(v); }; toggleExpanded = (e: React.MouseEvent) => { this.setState({ isExpanded: !this.state.isExpanded }); e.currentTarget.blur(); }; changeValue(idx: number) { return (v: number) => { const m = Mat4.copy(Mat4(), this.props.value); m[idx] = v; this.change(m); }; } get grid() { const v = this.props.value; const rows: React.ReactNode[] = []; for (let i = 0; i < 4; i++) { const row: React.ReactNode[] = []; for (let j = 0; j < 4; j++) { row.push(); } rows.push(
{row}
); } return
{rows}
; } render() { const v = { json: JSON.stringify(this.props.value) }; const label = this.props.param.label || camelCaseToWords(this.props.name); return <> {'4\u00D74 Matrix'}} /> {this.state.isExpanded &&
{this.grid}
} ; } } export class UrlControl extends SimpleParam { onChange = (e: React.ChangeEvent) => { const value = e.target.value; if (value !== Asset.getUrl(this.props.value || '')) { this.update(Asset.Url(value)); } }; onKeyPress = (e: React.KeyboardEvent) => { if ((e.keyCode === 13 || e.charCode === 13 || e.key === 'Enter')) { if (this.props.onEnter) this.props.onEnter(); } e.stopPropagation(); }; renderControl() { const placeholder = this.props.param.label || camelCaseToWords(this.props.name); return ; } } export class FileControl extends React.PureComponent> { state = { showHelp: false }; change(value: File) { this.props.onChange({ name: this.props.name, param: this.props.param, value: Asset.File(value) }); } onChangeFile = (e: React.ChangeEvent) => { this.change(e.target.files![0]); }; toggleHelp = () => this.setState({ showHelp: !this.state.showHelp }); renderControl() { const value = this.props.value; return
{value ? value.name : 'Select a file...'}
; } render() { if (this.props.param.label) { return renderSimple({ props: this.props, state: this.state, control: this.renderControl(), toggleHelp: this.toggleHelp, addOn: null }); } else { return this.renderControl(); } } } export class FileListControl extends React.PureComponent> { state = { showHelp: false }; change(value: FileList) { const files: Asset.File[] = []; if (value) { for (let i = 0, il = value.length; i < il; ++i) { files.push(Asset.File(value[i])); } } this.props.onChange({ name: this.props.name, param: this.props.param, value: files }); } onChangeFileList = (e: React.ChangeEvent) => { this.change(e.target.files!); }; toggleHelp = () => this.setState({ showHelp: !this.state.showHelp }); renderControl() { const value = this.props.value; const names: string[] = []; if (value) { for (const file of value) { names.push(file.name); } } const label = names.length === 0 ? 'Select files...' : names.length === 1 ? names[0] : `${names.length} files selected`; return
{label}
; } render() { if (this.props.param.label) { return renderSimple({ props: this.props, state: this.state, control: this.renderControl(), toggleHelp: this.toggleHelp, addOn: null }); } else { return this.renderControl(); } } } export class MultiSelectControl extends React.PureComponent>, { isExpanded: boolean }> { state = { isExpanded: false }; change(value: PD.MultiSelect['defaultValue']) { this.props.onChange({ name: this.props.name, param: this.props.param, value }); } toggle(key: string) { return (e: React.MouseEvent) => { if (this.props.value.indexOf(key) < 0) this.change(this.props.value.concat(key)); else this.change(this.props.value.filter(v => v !== key)); e.currentTarget.blur(); }; } toggleExpanded = (e: React.MouseEvent) => { this.setState({ isExpanded: !this.state.isExpanded }); e.currentTarget.blur(); }; render() { const current = this.props.value; const emptyLabel = this.props.param.emptyValue; const label = this.props.param.label || camelCaseToWords(this.props.name); return <> {current.length === 0 && emptyLabel ? emptyLabel : `${current.length} of ${this.props.param.options.length}`} } /> {this.state.isExpanded &&
{this.props.param.options.map(([value, label]) => { const sel = current.indexOf(value) >= 0; return ; })}
} ; } } export class GroupControl extends React.PureComponent> & { inMapped?: boolean }, { isExpanded: boolean, showPresets: boolean, showHelp: boolean }> { state = { isExpanded: !!this.props.param.isExpanded, showPresets: false, showHelp: false }; change(value: any) { this.props.onChange({ name: this.props.name, param: this.props.param, value }); } onChangeParam: ParamOnChange = e => { this.change({ ...this.props.value, [e.name]: e.value }); }; toggleExpanded = () => this.setState({ isExpanded: !this.state.isExpanded }); toggleShowPresets = () => this.setState({ showPresets: !this.state.showPresets }); presetItems = memoizeLatest((param: PD.Group) => ActionMenu.createItemsFromSelectOptions(param.presets ?? [])); onSelectPreset: ActionMenu.OnSelect = item => { this.setState({ showPresets: false }); this.change(item?.value); }; pivotedPresets() { if (!this.props.param.presets) return null; const label = this.props.param.label || camelCaseToWords(this.props.name); return
{this.state.showPresets && }
; } presets() { if (!this.props.param.presets) return null; return <>
{this.state.showPresets && } ; } pivoted() { const key = this.props.param.pivot as string; const params = this.props.param.params; const pivot = params[key]; const Control = controlFor(pivot)!; const ctrl = ; if (!this.state.isExpanded) { return
{ctrl}
; } const filtered = Object.create(null); for (const k of Object.keys(params)) { if (k !== key) filtered[k] = params[k]; } return
{ctrl}
{this.pivotedPresets()}
; } render() { const params = this.props.param.params; // Do not show if there are no params. if (Object.keys(params).length === 0) return null; if (this.props.param.pivot) return this.pivoted(); const label = this.props.param.label || camelCaseToWords(this.props.name); const controls = ; if (this.props.inMapped) { return
{controls}
; } if (this.props.param.isFlat) { return controls; } return
{this.presets()} {this.state.isExpanded &&
{controls}
}
; } } export class MappedControl extends React.PureComponent>, { isExpanded: boolean }> { state = { isExpanded: false }; // TODO: this could lead to a rare bug where the component is reused with different mapped control. // I think there are currently no cases where this could happen in the UI, but still need to watch out.. private valuesCache: { [name: string]: PD.Values } = {}; private setValues(name: string, values: PD.Values) { this.valuesCache[name] = values; } private getValues(name: string) { if (name in this.valuesCache) { return this.valuesCache[name]; } else { return this.props.param.map(name).defaultValue; } } change(value: PD.Mapped['defaultValue']) { this.props.onChange({ name: this.props.name, param: this.props.param, value }); } onChangeName: ParamOnChange = e => { this.change({ name: e.value, params: this.getValues(e.value) }); }; onChangeParam: ParamOnChange = e => { this.setValues(this.props.value.name, e.value); this.change({ name: this.props.value.name, params: e.value }); }; toggleExpanded = () => this.setState({ isExpanded: !this.state.isExpanded }); areParamsEmpty(params: PD.Params) { for (const k of Object.keys(params)) { if (!params[k].isHidden) return false; } return true; } render() { const value: PD.Mapped['defaultValue'] = this.props.value || this.props.param.defaultValue; const param = this.props.param.map(value.name); const label = this.props.param.label || camelCaseToWords(this.props.name); const Mapped = controlFor(param); const help = this.props.param.help; const select = help ? { ...this.props.param.select, help: (name: any) => help({ name, params: this.getValues(name) }) } : this.props.param.select; const Select = ; if (!Mapped) { return Select; } if (param.type === 'group' && !param.isFlat) { if (!this.areParamsEmpty(param.params)) { return
{Select} {this.state.isExpanded && }
; } return Select; } return <> {Select} ; } } type ObjectListEditorProps = { params: PD.Params, value: object, isUpdate?: boolean, apply: (value: any) => void, isDisabled?: boolean } class ObjectListEditor extends React.PureComponent { state = { current: this.props.value }; onChangeParam: ParamOnChange = e => { this.setState({ current: { ...this.state.current, [e.name]: e.value } }); }; apply = () => { this.props.apply(this.state.current); }; componentDidUpdate(prevProps: ObjectListEditorProps) { if (this.props.params !== prevProps.params || this.props.value !== prevProps.value) { this.setState({ current: this.props.value }); } } render() { return <> ; } } type ObjectListItemProps = { param: PD.ObjectList, value: object, index: number, actions: ObjectListControl['actions'], isDisabled?: boolean } class ObjectListItem extends React.PureComponent { state = { isExpanded: false }; update = (v: object) => { // this.setState({ isExpanded: false }); // TODO auto update? mark changed state? this.props.actions.update(v, this.props.index); }; moveUp = () => { this.props.actions.move(this.props.index, -1); }; moveDown = () => { this.props.actions.move(this.props.index, 1); }; remove = () => { this.setState({ isExpanded: false }); this.props.actions.remove(this.props.index); }; toggleExpanded = (e: React.MouseEvent) => { this.setState({ isExpanded: !this.state.isExpanded }); e.currentTarget.blur(); }; render() { return <>
{this.state.isExpanded &&
} ; } } export class ObjectListControl extends React.PureComponent, { isExpanded: boolean }> { state = { isExpanded: false }; change(value: any) { this.props.onChange({ name: this.props.name, param: this.props.param, value }); } add = (v: object) => { this.change([...this.props.value, v]); }; actions = { update: (v: object, i: number) => { const value = this.props.value.slice(0); value[i] = v; this.change(value); }, move: (i: number, dir: -1 | 1) => { let xs = this.props.value; if (xs.length === 1) return; let j = (i + dir) % xs.length; if (j < 0) j += xs.length; xs = xs.slice(0); const t = xs[i]; xs[i] = xs[j]; xs[j] = t; this.change(xs); }, remove: (i: number) => { const xs = this.props.value; const update: object[] = []; for (let j = 0; j < xs.length; j++) { if (i !== j) update.push(xs[j]); } this.change(update); } }; toggleExpanded = (e: React.MouseEvent) => { this.setState({ isExpanded: !this.state.isExpanded }); e.currentTarget.blur(); }; render() { const v = this.props.value; const label = this.props.param.label || camelCaseToWords(this.props.name); const value = `${v.length} item${v.length !== 1 ? 's' : ''}`; return <> {value}} /> {this.state.isExpanded &&
{this.props.value.map((v, i) => )}
} ; } } export class ConditionedControl extends React.PureComponent>> { change(value: PD.Conditioned['defaultValue']) { this.props.onChange({ name: this.props.name, param: this.props.param, value }); } onChangeCondition: ParamOnChange = e => { this.change(this.props.param.conditionedValue(this.props.value, e.value)); }; onChangeParam: ParamOnChange = e => { this.change(e.value); }; render() { const value = this.props.value; const condition = this.props.param.conditionForValue(value) as string; const param = this.props.param.conditionParams[condition]; const label = this.props.param.label || camelCaseToWords(this.props.name); const Conditioned = controlFor(param); const select = ; if (!Conditioned) { return select; } return <> {select} ; } } export class ConvertedControl extends React.PureComponent>> { onChange: ParamOnChange = e => { this.props.onChange({ name: this.props.name, param: this.props.param, value: this.props.param.toValue(e.value) }); }; render() { const value = this.props.param.fromValue(this.props.value); const Converted = controlFor(this.props.param.converted); if (!Converted) return null; return ; } } export class ScriptControl extends React.PureComponent> { onChange: ParamOnChange = ({ name, value }) => { const k = name as 'language' | 'expression'; if (value !== this.props.value[k]) { this.props.onChange({ param: this.props.param, name: this.props.name, value: { ...this.props.value, [k]: value } }); } }; render() { // TODO: improve! const selectParam: PD.Select = { defaultValue: this.props.value.language, options: PD.objectToOptions(Script.Info), type: 'select', }; const select = ; const textParam: PD.Text = { defaultValue: this.props.value.language, type: 'text', }; const text = ; return <> {select} {text} ; } }