parameters.tsx 59 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504
  1. /**
  2. * Copyright (c) 2018-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
  3. *
  4. * @author David Sehnal <david.sehnal@gmail.com>
  5. * @author Alexander Rose <alexander.rose@weirdbyte.de>
  6. */
  7. import * as React from 'react';
  8. import { Mat4, Vec2, Vec3 } from '../../mol-math/linear-algebra';
  9. import { Script } from '../../mol-script/script';
  10. import { Asset } from '../../mol-util/assets';
  11. import { Color } from '../../mol-util/color';
  12. import { ColorListEntry } from '../../mol-util/color/color';
  13. import { ColorListName, ColorListOptions, ColorListOptionsScale, ColorListOptionsSet, getColorListFromName } from '../../mol-util/color/lists';
  14. import { Legend as LegendData } from '../../mol-util/legend';
  15. import { memoize1, memoizeLatest } from '../../mol-util/memoize';
  16. import { getPrecision } from '../../mol-util/number';
  17. import { ParamDefinition as PD } from '../../mol-util/param-definition';
  18. import { ParamMapping } from '../../mol-util/param-mapping';
  19. import { camelCaseToWords } from '../../mol-util/string';
  20. import { PluginUIComponent } from '../base';
  21. import { PluginUIContext } from '../context';
  22. import { ActionMenu } from './action-menu';
  23. import { ColorOptions, ColorValueOption, CombinedColorControl } from './color';
  24. import { Button, ControlGroup, ControlRow, ExpandGroup, IconButton, TextInput, ToggleButton } from './common';
  25. import { ArrowDownwardSvg, ArrowDropDownSvg, ArrowRightSvg, ArrowUpwardSvg, BookmarksOutlinedSvg, CheckSvg, ClearSvg, DeleteOutlinedSvg, HelpOutlineSvg, Icon, MoreHorizSvg, WarningSvg } from './icons';
  26. import { legendFor } from './legend';
  27. import { LineGraphComponent } from './line-graph/line-graph-component';
  28. import { Slider, Slider2 } from './slider';
  29. export type ParameterControlsCategoryFilter = string | null | (string | null)[]
  30. export interface ParameterControlsProps<P extends PD.Params = PD.Params> {
  31. params: P,
  32. values: any,
  33. onChange?: ParamsOnChange<PD.ValuesFor<P>>,
  34. onChangeValues?: (values: PD.ValuesFor<P>, prev: PD.ValuesFor<P>) => void,
  35. isDisabled?: boolean,
  36. onEnter?: () => void
  37. }
  38. export class ParameterControls<P extends PD.Params> extends React.PureComponent<ParameterControlsProps<P>> {
  39. onChange: ParamOnChange = (params) => {
  40. this.props.onChange?.(params, this.props.values);
  41. if (this.props.onChangeValues) {
  42. const values = { ...this.props.values, [params.name]: params.value };
  43. this.props.onChangeValues(values, this.props.values);
  44. }
  45. };
  46. renderGroup(group: ParamInfo[]) {
  47. if (group.length === 0) return null;
  48. const values = this.props.values;
  49. let ctrls: JSX.Element[] | null = null;
  50. let category: string | undefined = void 0;
  51. for (const [key, p, Control] of group) {
  52. if (p.hideIf?.(values)) continue;
  53. if (!ctrls) ctrls = [];
  54. category = p.category;
  55. ctrls.push(<Control param={p} key={key} onChange={this.onChange} onEnter={this.props.onEnter} isDisabled={this.props.isDisabled} name={key} value={values[key]} />);
  56. }
  57. if (!ctrls) return null;
  58. if (category) {
  59. return [<ExpandGroup key={category} header={category}>{ctrls}</ExpandGroup>];
  60. }
  61. return ctrls;
  62. }
  63. renderPart(groups: ParamInfo[][]) {
  64. let parts: JSX.Element[] | null = null;
  65. for (const g of groups) {
  66. const ctrls = this.renderGroup(g);
  67. if (!ctrls) continue;
  68. if (!parts) parts = [];
  69. for (const c of ctrls) parts.push(c);
  70. }
  71. return parts;
  72. }
  73. paramGroups = memoizeLatest((params: PD.Params) => classifyParams(params));
  74. render() {
  75. const groups = this.paramGroups(this.props.params);
  76. const essentials = this.renderPart(groups.essentials);
  77. const advanced = this.renderPart(groups.advanced);
  78. if (essentials && advanced) {
  79. return <>
  80. {essentials}
  81. <ExpandGroup header='Advanced Options'>
  82. {advanced}
  83. </ExpandGroup>
  84. </>;
  85. } else if (essentials) {
  86. return essentials;
  87. } else {
  88. return advanced;
  89. }
  90. }
  91. }
  92. export class ParameterMappingControl<S, T> extends PluginUIComponent<{ mapping: ParamMapping<S, T, PluginUIContext> }> {
  93. setSettings = (p: { param: PD.Base<any>, name: string, value: any }, old: any) => {
  94. const values = { ...old, [p.name]: p.value };
  95. const t = this.props.mapping.update(values, this.plugin);
  96. this.props.mapping.apply(t, this.plugin);
  97. };
  98. componentDidMount() {
  99. this.subscribe(this.plugin.events.canvas3d.settingsUpdated, () => this.forceUpdate());
  100. }
  101. render() {
  102. const t = this.props.mapping.getTarget(this.plugin);
  103. const values = this.props.mapping.getValues(t, this.plugin);
  104. const params = this.props.mapping.params(this.plugin) as any as PD.Params;
  105. return <ParameterControls params={params} values={values} onChange={this.setSettings} />;
  106. }
  107. }
  108. type ParamInfo = [string, PD.Any, ParamControl];
  109. function classifyParams(params: PD.Params) {
  110. function addParam(k: string, p: PD.Any, group: typeof essentials) {
  111. const ctrl = controlFor(p);
  112. if (!ctrl) return;
  113. if (!p.category) group.params[0].push([k, p, ctrl]);
  114. else {
  115. if (!group.map) group.map = new Map();
  116. let c = group.map.get(p.category);
  117. if (!c) {
  118. c = [];
  119. group.map.set(p.category, c);
  120. group.params.push(c);
  121. }
  122. c.push([k, p, ctrl]);
  123. }
  124. }
  125. function sortGroups(x: ParamInfo[], y: ParamInfo[]) {
  126. const a = x[0], b = y[0];
  127. if (!a || !a[1].category) return -1;
  128. if (!b || !b[1].category) return 1;
  129. return a[1].category < b[1].category ? -1 : 1;
  130. }
  131. const keys = Object.keys(params);
  132. const essentials: { params: ParamInfo[][], map: Map<string, ParamInfo[]> | undefined } = { params: [[]], map: void 0 };
  133. const advanced: typeof essentials = { params: [[]], map: void 0 };
  134. for (const k of keys) {
  135. const p = params[k];
  136. if (p.isHidden) continue;
  137. if (p.isEssential) addParam(k, p, essentials);
  138. else addParam(k, p, advanced);
  139. }
  140. essentials.params.sort(sortGroups);
  141. advanced.params.sort(sortGroups);
  142. return { essentials: essentials.params, advanced: advanced.params };
  143. }
  144. function controlFor(param: PD.Any): ParamControl | undefined {
  145. switch (param.type) {
  146. case 'value': return void 0;
  147. case 'boolean': return BoolControl;
  148. case 'number': return typeof param.min !== 'undefined' && typeof param.max !== 'undefined'
  149. ? NumberRangeControl : NumberInputControl;
  150. case 'converted': return ConvertedControl;
  151. case 'conditioned': return ConditionedControl;
  152. case 'multi-select': return MultiSelectControl;
  153. case 'color': return CombinedColorControl;
  154. case 'color-list': return param.offsets ? OffsetColorListControl : ColorListControl;
  155. case 'vec3': return Vec3Control;
  156. case 'mat4': return Mat4Control;
  157. case 'url': return UrlControl;
  158. case 'file': return FileControl;
  159. case 'file-list': return FileListControl;
  160. case 'select': return SelectControl;
  161. case 'value-ref': return ValueRefControl;
  162. case 'data-ref': return void 0;
  163. case 'text': return TextControl;
  164. case 'interval': return typeof param.min !== 'undefined' && typeof param.max !== 'undefined'
  165. ? BoundedIntervalControl : IntervalControl;
  166. case 'group': return GroupControl;
  167. case 'mapped': return MappedControl;
  168. case 'line-graph': return LineGraphControl;
  169. case 'script': return ScriptControl;
  170. case 'object-list': return ObjectListControl;
  171. default:
  172. const _: never = param;
  173. console.warn(`${_} has no associated UI component`);
  174. return void 0;
  175. }
  176. }
  177. export class ParamHelp<L extends LegendData> extends React.PureComponent<{ legend?: L, description?: string }> {
  178. render() {
  179. const { legend, description } = this.props;
  180. const Legend = legend && legendFor(legend);
  181. return <div className='msp-help-text'>
  182. <div>
  183. <div className='msp-help-description'><Icon svg={HelpOutlineSvg} inline />{description}</div>
  184. {Legend && <div className='msp-help-legend'><Legend legend={legend} /></div>}
  185. </div>
  186. </div>;
  187. }
  188. }
  189. export type ParamsOnChange<P> = (params: { param: PD.Base<any>, name: string, value: any }, values: Readonly<P>) => void
  190. export type ParamOnChange = (params: { param: PD.Base<any>, name: string, value: any }) => void
  191. export interface ParamProps<P extends PD.Base<any> = PD.Base<any>> {
  192. name: string,
  193. value: P['defaultValue'],
  194. param: P,
  195. isDisabled?: boolean,
  196. onChange: ParamOnChange,
  197. onEnter?: () => void
  198. }
  199. export type ParamControl = React.ComponentClass<ParamProps<any>>
  200. function renderSimple(options: { props: ParamProps<any>, state: { showHelp: boolean }, control: JSX.Element, addOn: JSX.Element | null, toggleHelp: () => void }) {
  201. const { props, state, control, toggleHelp, addOn } = options;
  202. const _className = [];
  203. if (props.param.shortLabel) _className.push('msp-control-label-short');
  204. if (props.param.twoColumns) _className.push('msp-control-col-2');
  205. const className = _className.join(' ');
  206. const label = props.param.label || camelCaseToWords(props.name);
  207. const help = props.param.help
  208. ? props.param.help(props.value)
  209. : { description: props.param.description, legend: props.param.legend };
  210. const hasHelp = help.description || help.legend;
  211. const desc = label + (hasHelp ? '. Click for help.' : '');
  212. return <>
  213. <ControlRow
  214. className={className}
  215. title={desc}
  216. label={<>
  217. {label}
  218. {hasHelp &&
  219. <button className='msp-help msp-btn-link msp-btn-icon msp-control-group-expander' onClick={toggleHelp}
  220. title={desc || `${state.showHelp ? 'Hide' : 'Show'} help`}
  221. style={{ background: 'transparent', textAlign: 'left', padding: '0' }}>
  222. <Icon svg={HelpOutlineSvg} />
  223. </button>
  224. }
  225. </>}
  226. control={control}
  227. />
  228. {hasHelp && state.showHelp && <div className='msp-control-offset'>
  229. <ParamHelp legend={help.legend} description={help.description} />
  230. </div>}
  231. {addOn}
  232. </>;
  233. }
  234. export abstract class SimpleParam<P extends PD.Any> extends React.PureComponent<ParamProps<P>, { showHelp: boolean }> {
  235. state = { showHelp: false };
  236. protected update(value: P['defaultValue']) {
  237. this.props.onChange({ param: this.props.param, name: this.props.name, value });
  238. }
  239. abstract renderControl(): JSX.Element;
  240. renderAddOn(): JSX.Element | null { return null; }
  241. toggleHelp = () => this.setState({ showHelp: !this.state.showHelp });
  242. render() {
  243. return renderSimple({
  244. props: this.props,
  245. state: this.state,
  246. control: this.renderControl(),
  247. toggleHelp: this.toggleHelp,
  248. addOn: this.renderAddOn()
  249. });
  250. }
  251. }
  252. export class BoolControl extends SimpleParam<PD.BooleanParam> {
  253. onClick = (e: React.MouseEvent<HTMLButtonElement>) => { this.update(!this.props.value); e.currentTarget.blur(); };
  254. renderControl() {
  255. return <button onClick={this.onClick} disabled={this.props.isDisabled}>
  256. <Icon svg={this.props.value ? CheckSvg : ClearSvg} />
  257. {this.props.value ? 'On' : 'Off'}
  258. </button>;
  259. }
  260. }
  261. export class LineGraphControl extends React.PureComponent<ParamProps<PD.LineGraph>, { isExpanded: boolean, isOverPoint: boolean, message: string }> {
  262. state = {
  263. isExpanded: false,
  264. isOverPoint: false,
  265. message: `${this.props.param.defaultValue.length} points`,
  266. };
  267. onHover = (point?: Vec2) => {
  268. this.setState({ isOverPoint: !this.state.isOverPoint });
  269. if (point) {
  270. this.setState({ message: `(${point[0].toFixed(2)}, ${point[1].toFixed(2)})` });
  271. return;
  272. }
  273. this.setState({ message: `${this.props.value.length} points` });
  274. };
  275. onDrag = (point: Vec2) => {
  276. this.setState({ message: `(${point[0].toFixed(2)}, ${point[1].toFixed(2)})` });
  277. };
  278. onChange = (value: PD.LineGraph['defaultValue']) => {
  279. this.props.onChange({ name: this.props.name, param: this.props.param, value: value });
  280. };
  281. toggleExpanded = (e: React.MouseEvent<HTMLButtonElement>) => {
  282. this.setState({ isExpanded: !this.state.isExpanded });
  283. e.currentTarget.blur();
  284. };
  285. render() {
  286. const label = this.props.param.label || camelCaseToWords(this.props.name);
  287. return <>
  288. <ControlRow label={label} control={<button onClick={this.toggleExpanded} disabled={this.props.isDisabled}>{`${this.state.message}`}</button>} />
  289. <div className='msp-control-offset' style={{ display: this.state.isExpanded ? 'block' : 'none' }}>
  290. <LineGraphComponent
  291. data={this.props.param.defaultValue}
  292. onChange={this.onChange}
  293. onHover={this.onHover}
  294. onDrag={this.onDrag} />
  295. </div>
  296. </>;
  297. }
  298. }
  299. export class NumberInputControl extends React.PureComponent<ParamProps<PD.Numeric>> {
  300. state = { value: '0' };
  301. update = (value: number) => {
  302. const p = getPrecision(this.props.param.step || 0.01);
  303. value = parseFloat(value.toFixed(p));
  304. this.props.onChange({ param: this.props.param, name: this.props.name, value });
  305. };
  306. render() {
  307. const placeholder = this.props.param.label || camelCaseToWords(this.props.name);
  308. const label = this.props.param.label || camelCaseToWords(this.props.name);
  309. const p = getPrecision(this.props.param.step || 0.01);
  310. return <ControlRow
  311. title={this.props.param.description}
  312. label={label}
  313. control={<TextInput numeric
  314. value={parseFloat(this.props.value.toFixed(p))} onEnter={this.props.onEnter} placeholder={placeholder}
  315. isDisabled={this.props.isDisabled} onChange={this.update} />} />;
  316. }
  317. }
  318. export class NumberRangeControl extends SimpleParam<PD.Numeric> {
  319. onChange = (v: number) => { this.update(v); };
  320. renderControl() {
  321. const value = typeof this.props.value === 'undefined' ? this.props.param.defaultValue : this.props.value;
  322. return <Slider value={value} min={this.props.param.min!} max={this.props.param.max!}
  323. step={this.props.param.step} onChange={this.onChange} onChangeImmediate={this.props.param.immediateUpdate ? this.onChange : void 0}
  324. disabled={this.props.isDisabled} onEnter={this.props.onEnter} />;
  325. }
  326. }
  327. export class TextControl extends SimpleParam<PD.Text> {
  328. onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  329. const value = e.target.value;
  330. if (value !== this.props.value) {
  331. this.update(value);
  332. }
  333. };
  334. onKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
  335. if ((e.keyCode === 13 || e.charCode === 13 || e.key === 'Enter')) {
  336. if (this.props.onEnter) this.props.onEnter();
  337. }
  338. e.stopPropagation();
  339. };
  340. renderControl() {
  341. const placeholder = this.props.param.label || camelCaseToWords(this.props.name);
  342. return <input type='text'
  343. value={this.props.value || ''}
  344. placeholder={placeholder}
  345. onChange={this.onChange}
  346. onKeyPress={this.props.onEnter ? this.onKeyPress : void 0}
  347. disabled={this.props.isDisabled}
  348. />;
  349. }
  350. }
  351. export class PureSelectControl extends React.PureComponent<ParamProps<PD.Select<string | number>> & { title?: string }> {
  352. protected update(value: string | number) {
  353. this.props.onChange({ param: this.props.param, name: this.props.name, value });
  354. }
  355. onChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
  356. if (typeof this.props.param.defaultValue === 'number') {
  357. this.update(parseInt(e.target.value, 10));
  358. } else {
  359. this.update(e.target.value);
  360. }
  361. };
  362. render() {
  363. const isInvalid = this.props.value !== void 0 && !this.props.param.options.some(e => e[0] === this.props.value);
  364. return <select className='msp-form-control' title={this.props.title} value={this.props.value !== void 0 ? this.props.value : this.props.param.defaultValue} onChange={this.onChange} disabled={this.props.isDisabled}>
  365. {isInvalid && <option key={this.props.value} value={this.props.value}>{`[Invalid] ${this.props.value}`}</option>}
  366. {this.props.param.options.map(([value, label]) => <option key={value} value={value}>{label}</option>)}
  367. </select>;
  368. }
  369. }
  370. export class SelectControl extends React.PureComponent<ParamProps<PD.Select<string | number>>, { showHelp: boolean, showOptions: boolean }> {
  371. state = { showHelp: false, showOptions: false };
  372. onSelect: ActionMenu.OnSelect = item => {
  373. if (!item || item.value === this.props.value) {
  374. this.setState({ showOptions: false });
  375. } else {
  376. this.setState({ showOptions: false }, () => {
  377. this.props.onChange({ param: this.props.param, name: this.props.name, value: item.value });
  378. });
  379. }
  380. };
  381. toggle = () => this.setState({ showOptions: !this.state.showOptions });
  382. cycle = () => {
  383. const { options } = this.props.param;
  384. const current = options.findIndex(o => o[0] === this.props.value);
  385. const next = current === options.length - 1 ? 0 : current + 1;
  386. this.props.onChange({ param: this.props.param, name: this.props.name, value: options[next][0] });
  387. };
  388. items = memoizeLatest((param: PD.Select<any>) => ActionMenu.createItemsFromSelectOptions(param.options));
  389. renderControl() {
  390. const items = this.items(this.props.param);
  391. const current = this.props.value !== undefined ? ActionMenu.findItem(items, this.props.value) : void 0;
  392. const label = current
  393. ? current.label
  394. : typeof this.props.value === 'undefined'
  395. ? `${ActionMenu.getFirstItem(items)?.label || ''} [Default]`
  396. : `[Invalid] ${this.props.value}`;
  397. const toggle = this.props.param.cycle ? this.cycle : this.toggle;
  398. const textAlign = this.props.param.cycle ? 'center' : 'left';
  399. const icon = this.props.param.cycle
  400. ? (this.props.value === 'on' ? CheckSvg
  401. : this.props.value === 'off' ? ClearSvg : void 0)
  402. : void 0;
  403. return <ToggleButton disabled={this.props.isDisabled} style={{ textAlign, overflow: 'hidden', textOverflow: 'ellipsis' }}
  404. label={label} title={label as string} icon={icon} toggle={toggle} isSelected={this.state.showOptions} />;
  405. }
  406. renderAddOn() {
  407. if (!this.state.showOptions) return null;
  408. const items = this.items(this.props.param);
  409. const current = ActionMenu.findItem(items, this.props.value);
  410. return <ActionMenu items={items} current={current} onSelect={this.onSelect} />;
  411. }
  412. toggleHelp = () => this.setState({ showHelp: !this.state.showHelp });
  413. render() {
  414. return renderSimple({
  415. props: this.props,
  416. state: this.state,
  417. control: this.renderControl(),
  418. toggleHelp: this.toggleHelp,
  419. addOn: this.renderAddOn()
  420. });
  421. }
  422. }
  423. export class ValueRefControl extends React.PureComponent<ParamProps<PD.ValueRef<any>>, { showHelp: boolean, showOptions: boolean }> {
  424. state = { showHelp: false, showOptions: false };
  425. onSelect: ActionMenu.OnSelect = item => {
  426. if (!item || item.value === this.props.value) {
  427. this.setState({ showOptions: false });
  428. } else {
  429. this.setState({ showOptions: false }, () => {
  430. this.props.onChange({ param: this.props.param, name: this.props.name, value: { ref: item.value } });
  431. });
  432. }
  433. };
  434. toggle = () => this.setState({ showOptions: !this.state.showOptions });
  435. items = memoizeLatest((param: PD.ValueRef) => ActionMenu.createItemsFromSelectOptions(param.getOptions()));
  436. renderControl() {
  437. const items = this.items(this.props.param);
  438. const current = this.props.value.ref ? ActionMenu.findItem(items, this.props.value.ref) : void 0;
  439. const label = current
  440. ? current.label
  441. : `[Ref] ${this.props.value.ref ?? ''}`;
  442. return <ToggleButton disabled={this.props.isDisabled} style={{ textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis' }}
  443. label={label} title={label as string} toggle={this.toggle} isSelected={this.state.showOptions} />;
  444. }
  445. renderAddOn() {
  446. if (!this.state.showOptions) return null;
  447. const items = this.items(this.props.param);
  448. const current = ActionMenu.findItem(items, this.props.value.ref);
  449. return <ActionMenu items={items} current={current} onSelect={this.onSelect} />;
  450. }
  451. toggleHelp = () => this.setState({ showHelp: !this.state.showHelp });
  452. render() {
  453. return renderSimple({
  454. props: this.props,
  455. state: this.state,
  456. control: this.renderControl(),
  457. toggleHelp: this.toggleHelp,
  458. addOn: this.renderAddOn()
  459. });
  460. }
  461. }
  462. export class IntervalControl extends React.PureComponent<ParamProps<PD.Interval>, { isExpanded: boolean }> {
  463. state = { isExpanded: false };
  464. components = {
  465. 0: PD.Numeric(0, { step: this.props.param.step }, { label: 'Min' }),
  466. 1: PD.Numeric(0, { step: this.props.param.step }, { label: 'Max' })
  467. };
  468. change(value: PD.MultiSelect<any>['defaultValue']) {
  469. this.props.onChange({ name: this.props.name, param: this.props.param, value });
  470. }
  471. componentChange: ParamOnChange = ({ name, value }) => {
  472. const v = [...this.props.value];
  473. v[+name] = value;
  474. this.change(v);
  475. };
  476. toggleExpanded = (e: React.MouseEvent<HTMLButtonElement>) => {
  477. this.setState({ isExpanded: !this.state.isExpanded });
  478. e.currentTarget.blur();
  479. };
  480. render() {
  481. const v = this.props.value;
  482. const label = this.props.param.label || camelCaseToWords(this.props.name);
  483. const p = getPrecision(this.props.param.step || 0.01);
  484. const value = `[${v[0].toFixed(p)}, ${v[1].toFixed(p)}]`;
  485. return <>
  486. <ControlRow label={label} control={<button onClick={this.toggleExpanded} disabled={this.props.isDisabled}>{value}</button>} />
  487. {this.state.isExpanded && <div className='msp-control-offset'>
  488. <ParameterControls params={this.components} values={v} onChange={this.componentChange} onEnter={this.props.onEnter} />
  489. </div>}
  490. </>;
  491. }
  492. }
  493. export class BoundedIntervalControl extends SimpleParam<PD.Interval> {
  494. onChange = (v: [number, number]) => { this.update(v); };
  495. renderControl() {
  496. return <Slider2 value={this.props.value} min={this.props.param.min!} max={this.props.param.max!}
  497. step={this.props.param.step} onChange={this.onChange} disabled={this.props.isDisabled} onEnter={this.props.onEnter} />;
  498. }
  499. }
  500. export class ColorControl extends SimpleParam<PD.Color> {
  501. onChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
  502. this.update(Color(parseInt(e.target.value)));
  503. };
  504. stripStyle(): React.CSSProperties {
  505. return {
  506. background: Color.toStyle(this.props.value),
  507. position: 'absolute',
  508. bottom: '0',
  509. height: '4px',
  510. right: '0',
  511. left: '0'
  512. };
  513. }
  514. renderControl() {
  515. return <div style={{ position: 'relative' }}>
  516. <select value={this.props.value} onChange={this.onChange}>
  517. {ColorValueOption(this.props.value)}
  518. {ColorOptions()}
  519. </select>
  520. <div style={this.stripStyle()} />
  521. </div>;
  522. }
  523. }
  524. function colorEntryToStyle(e: ColorListEntry, includeOffset = false) {
  525. if (Array.isArray(e)) {
  526. if (includeOffset) return `${Color.toStyle(e[0])} ${(100 * e[1]).toFixed(2)}%`;
  527. return Color.toStyle(e[0]);
  528. }
  529. return Color.toStyle(e);
  530. }
  531. const colorGradientInterpolated = memoize1((colors: ColorListEntry[]) => {
  532. const styles = colors.map(c => colorEntryToStyle(c, true));
  533. return `linear-gradient(to right, ${styles.join(', ')})`;
  534. });
  535. const colorGradientBanded = memoize1((colors: ColorListEntry[]) => {
  536. const n = colors.length;
  537. const styles: string[] = [`${colorEntryToStyle(colors[0])} ${100 * (1 / n)}%`];
  538. // TODO: does this need to support offsets?
  539. for (let i = 1, il = n - 1; i < il; ++i) {
  540. styles.push(
  541. `${colorEntryToStyle(colors[i])} ${100 * (i / n)}%`,
  542. `${colorEntryToStyle(colors[i])} ${100 * ((i + 1) / n)}%`
  543. );
  544. }
  545. styles.push(`${colorEntryToStyle(colors[n - 1])} ${100 * ((n - 1) / n)}%`);
  546. return `linear-gradient(to right, ${styles.join(', ')})`;
  547. });
  548. function colorStripStyle(list: PD.ColorList['defaultValue'], right = '0'): React.CSSProperties {
  549. return {
  550. background: colorGradient(list.colors, list.kind === 'set'),
  551. position: 'absolute',
  552. bottom: '0',
  553. height: '4px',
  554. right,
  555. left: '0'
  556. };
  557. }
  558. function colorGradient(colors: ColorListEntry[], banded: boolean) {
  559. return banded ? colorGradientBanded(colors) : colorGradientInterpolated(colors);
  560. }
  561. function createColorListHelpers() {
  562. const addOn = (l: [ColorListName, any, any]) => {
  563. const preset = getColorListFromName(l[0]);
  564. return <div style={colorStripStyle({ kind: preset.type !== 'qualitative' ? 'interpolate' : 'set', colors: preset.list })} />;
  565. };
  566. return {
  567. ColorPresets: {
  568. all: ActionMenu.createItemsFromSelectOptions(ColorListOptions, { addOn }),
  569. scale: ActionMenu.createItemsFromSelectOptions(ColorListOptionsScale, { addOn }),
  570. set: ActionMenu.createItemsFromSelectOptions(ColorListOptionsSet, { addOn })
  571. },
  572. ColorsParam: PD.ObjectList({ color: PD.Color(0x0 as Color) }, ({ color }) => Color.toHexString(color).toUpperCase()),
  573. OffsetColorsParam: PD.ObjectList(
  574. { color: PD.Color(0x0 as Color), offset: PD.Numeric(0, { min: 0, max: 1, step: 0.01 }) },
  575. ({ color, offset }) => `${Color.toHexString(color).toUpperCase()} [${offset.toFixed(2)}]`),
  576. IsInterpolatedParam: PD.Boolean(false, { label: 'Interpolated' })
  577. };
  578. }
  579. let _colorListHelpers: ReturnType<typeof createColorListHelpers>;
  580. function ColorListHelpers() {
  581. if (_colorListHelpers) return _colorListHelpers;
  582. _colorListHelpers = createColorListHelpers();
  583. return _colorListHelpers;
  584. }
  585. export class ColorListControl extends React.PureComponent<ParamProps<PD.ColorList>, { showHelp: boolean, show?: 'edit' | 'presets' }> {
  586. state = { showHelp: false, show: void 0 as 'edit' | 'presets' | undefined };
  587. protected update(value: PD.ColorList['defaultValue']) {
  588. this.props.onChange({ param: this.props.param, name: this.props.name, value });
  589. }
  590. toggleEdit = () => this.setState({ show: this.state.show === 'edit' ? void 0 : 'edit' });
  591. togglePresets = () => this.setState({ show: this.state.show === 'presets' ? void 0 : 'presets' });
  592. renderControl() {
  593. const { value } = this.props;
  594. // TODO: fix the button right offset
  595. return <>
  596. <button onClick={this.toggleEdit} style={{ position: 'relative', paddingRight: '33px' }}>
  597. {value.colors.length === 1 ? '1 color' : `${value.colors.length} colors`}
  598. <div style={colorStripStyle(value, '33px')} />
  599. </button>
  600. <IconButton svg={BookmarksOutlinedSvg} onClick={this.togglePresets} toggleState={this.state.show === 'presets'} title='Color Presets'
  601. style={{ padding: 0, position: 'absolute', right: 0, top: 0, width: '32px' }} />
  602. </>;
  603. }
  604. selectPreset: ActionMenu.OnSelect = item => {
  605. if (!item) return;
  606. this.setState({ show: void 0 });
  607. const preset = getColorListFromName(item.value as ColorListName);
  608. this.update({ kind: preset.type !== 'qualitative' ? 'interpolate' : 'set', colors: preset.list });
  609. };
  610. colorsChanged: ParamOnChange = ({ value }) => {
  611. this.update({
  612. kind: this.props.value.kind,
  613. colors: (value as (typeof _colorListHelpers)['ColorsParam']['defaultValue']).map(c => c.color)
  614. });
  615. };
  616. isInterpolatedChanged: ParamOnChange = ({ value }) => {
  617. this.update({ kind: value ? 'interpolate' : 'set', colors: this.props.value.colors });
  618. };
  619. renderColors() {
  620. if (!this.state.show) return null;
  621. const { ColorPresets, ColorsParam, IsInterpolatedParam } = ColorListHelpers();
  622. const preset = ColorPresets[this.props.param.presetKind];
  623. if (this.state.show === 'presets') return <ActionMenu items={preset} onSelect={this.selectPreset} />;
  624. const values = this.props.value.colors.map(color => ({ color }));
  625. return <div className='msp-control-offset'>
  626. <ObjectListControl name='colors' param={ColorsParam} value={values} onChange={this.colorsChanged} isDisabled={this.props.isDisabled} onEnter={this.props.onEnter} />
  627. <BoolControl name='isInterpolated' param={IsInterpolatedParam} value={this.props.value.kind === 'interpolate'} onChange={this.isInterpolatedChanged} isDisabled={this.props.isDisabled} onEnter={this.props.onEnter} />
  628. </div>;
  629. }
  630. toggleHelp = () => this.setState({ showHelp: !this.state.showHelp });
  631. render() {
  632. return renderSimple({
  633. props: this.props,
  634. state: this.state,
  635. control: this.renderControl(),
  636. toggleHelp: this.toggleHelp,
  637. addOn: this.renderColors()
  638. });
  639. }
  640. }
  641. export class OffsetColorListControl extends React.PureComponent<ParamProps<PD.ColorList>, { showHelp: boolean, show?: 'edit' | 'presets' }> {
  642. state = { showHelp: false, show: void 0 as 'edit' | 'presets' | undefined };
  643. protected update(value: PD.ColorList['defaultValue']) {
  644. this.props.onChange({ param: this.props.param, name: this.props.name, value });
  645. }
  646. toggleEdit = () => this.setState({ show: this.state.show === 'edit' ? void 0 : 'edit' });
  647. togglePresets = () => this.setState({ show: this.state.show === 'presets' ? void 0 : 'presets' });
  648. renderControl() {
  649. const { value } = this.props;
  650. // TODO: fix the button right offset
  651. return <>
  652. <button onClick={this.toggleEdit} style={{ position: 'relative', paddingRight: '33px' }}>
  653. {value.colors.length === 1 ? '1 color' : `${value.colors.length} colors`}
  654. <div style={colorStripStyle(value, '33px')} />
  655. </button>
  656. <IconButton svg={BookmarksOutlinedSvg} onClick={this.togglePresets} toggleState={this.state.show === 'presets'} title='Color Presets'
  657. style={{ padding: 0, position: 'absolute', right: 0, top: 0, width: '32px' }} />
  658. </>;
  659. }
  660. selectPreset: ActionMenu.OnSelect = item => {
  661. if (!item) return;
  662. this.setState({ show: void 0 });
  663. const preset = getColorListFromName(item.value as ColorListName);
  664. this.update({ kind: preset.type !== 'qualitative' ? 'interpolate' : 'set', colors: preset.list });
  665. };
  666. colorsChanged: ParamOnChange = ({ value }) => {
  667. const colors = (value as (typeof _colorListHelpers)['OffsetColorsParam']['defaultValue']).map(c => [c.color, c.offset] as [Color, number]);
  668. colors.sort((a, b) => a[1] - b[1]);
  669. this.update({ kind: this.props.value.kind, colors });
  670. };
  671. isInterpolatedChanged: ParamOnChange = ({ value }) => {
  672. this.update({ kind: value ? 'interpolate' : 'set', colors: this.props.value.colors });
  673. };
  674. renderColors() {
  675. if (!this.state.show) return null;
  676. const { ColorPresets, OffsetColorsParam, IsInterpolatedParam } = ColorListHelpers();
  677. const preset = ColorPresets[this.props.param.presetKind];
  678. if (this.state.show === 'presets') return <ActionMenu items={preset} onSelect={this.selectPreset} />;
  679. const colors = this.props.value.colors;
  680. const values = colors.map((color, i) => {
  681. if (Array.isArray(color)) return { color: color[0], offset: color[1] };
  682. return { color, offset: i / colors.length };
  683. });
  684. values.sort((a, b) => a.offset - b.offset);
  685. return <div className='msp-control-offset'>
  686. <ObjectListControl name='colors' param={OffsetColorsParam} value={values} onChange={this.colorsChanged} isDisabled={this.props.isDisabled} onEnter={this.props.onEnter} />
  687. <BoolControl name='isInterpolated' param={IsInterpolatedParam} value={this.props.value.kind === 'interpolate'} onChange={this.isInterpolatedChanged} isDisabled={this.props.isDisabled} onEnter={this.props.onEnter} />
  688. </div>;
  689. }
  690. toggleHelp = () => this.setState({ showHelp: !this.state.showHelp });
  691. render() {
  692. return renderSimple({
  693. props: this.props,
  694. state: this.state,
  695. control: this.renderControl(),
  696. toggleHelp: this.toggleHelp,
  697. addOn: this.renderColors()
  698. });
  699. }
  700. }
  701. export class Vec3Control extends React.PureComponent<ParamProps<PD.Vec3>, { isExpanded: boolean }> {
  702. state = { isExpanded: false };
  703. components = {
  704. 0: PD.Numeric(0, { step: this.props.param.step }, { label: (this.props.param.fieldLabels && this.props.param.fieldLabels.x) || 'X' }),
  705. 1: PD.Numeric(0, { step: this.props.param.step }, { label: (this.props.param.fieldLabels && this.props.param.fieldLabels.y) || 'Y' }),
  706. 2: PD.Numeric(0, { step: this.props.param.step }, { label: (this.props.param.fieldLabels && this.props.param.fieldLabels.z) || 'Z' })
  707. };
  708. change(value: PD.MultiSelect<any>['defaultValue']) {
  709. this.props.onChange({ name: this.props.name, param: this.props.param, value });
  710. }
  711. componentChange: ParamOnChange = ({ name, value }) => {
  712. const v = Vec3.copy(Vec3.zero(), this.props.value);
  713. v[+name] = value;
  714. this.change(v);
  715. };
  716. toggleExpanded = (e: React.MouseEvent<HTMLButtonElement>) => {
  717. this.setState({ isExpanded: !this.state.isExpanded });
  718. e.currentTarget.blur();
  719. };
  720. render() {
  721. const v = this.props.value;
  722. const label = this.props.param.label || camelCaseToWords(this.props.name);
  723. const p = getPrecision(this.props.param.step || 0.01);
  724. const value = `[${v[0].toFixed(p)}, ${v[1].toFixed(p)}, ${v[2].toFixed(p)}]`;
  725. return <>
  726. <ControlRow label={label} control={<button onClick={this.toggleExpanded} disabled={this.props.isDisabled}>{value}</button>} />
  727. {this.state.isExpanded && <div className='msp-control-offset'>
  728. <ParameterControls params={this.components} values={v} onChange={this.componentChange} onEnter={this.props.onEnter} />
  729. </div>}
  730. </>;
  731. }
  732. }
  733. export class Mat4Control extends React.PureComponent<ParamProps<PD.Mat4>, { isExpanded: boolean }> {
  734. state = { isExpanded: false };
  735. components = {
  736. json: PD.Text(JSON.stringify(Mat4()), { description: 'JSON array with 4x4 matrix in a column major (j * 4 + i indexing) format' })
  737. };
  738. change(value: PD.MultiSelect<any>['defaultValue']) {
  739. this.props.onChange({ name: this.props.name, param: this.props.param, value });
  740. }
  741. componentChange: ParamOnChange = ({ name, value }) => {
  742. const v = Mat4.copy(Mat4(), this.props.value);
  743. if (name === 'json') {
  744. Mat4.copy(v, JSON.parse(value));
  745. } else {
  746. v[+name] = value;
  747. }
  748. this.change(v);
  749. };
  750. toggleExpanded = (e: React.MouseEvent<HTMLButtonElement>) => {
  751. this.setState({ isExpanded: !this.state.isExpanded });
  752. e.currentTarget.blur();
  753. };
  754. changeValue(idx: number) {
  755. return (v: number) => {
  756. const m = Mat4.copy(Mat4(), this.props.value);
  757. m[idx] = v;
  758. this.change(m);
  759. };
  760. }
  761. get grid() {
  762. const v = this.props.value;
  763. const rows: React.ReactNode[] = [];
  764. for (let i = 0; i < 4; i++) {
  765. const row: React.ReactNode[] = [];
  766. for (let j = 0; j < 4; j++) {
  767. row.push(<TextInput key={j} numeric delayMs={50} value={Mat4.getValue(v, i, j)} onChange={this.changeValue(4 * j + i)} className='msp-form-control' blurOnEnter={true} isDisabled={this.props.isDisabled} />);
  768. }
  769. rows.push(<div className='msp-flex-row' key={i}>{row}</div>);
  770. }
  771. return <div className='msp-parameter-matrix'>{rows}</div>;
  772. }
  773. render() {
  774. const v = {
  775. json: JSON.stringify(this.props.value)
  776. };
  777. const label = this.props.param.label || camelCaseToWords(this.props.name);
  778. return <>
  779. <ControlRow label={label} control={<button onClick={this.toggleExpanded} disabled={this.props.isDisabled}>{'4\u00D74 Matrix'}</button>} />
  780. {this.state.isExpanded && <div className='msp-control-offset'>
  781. {this.grid}
  782. <ParameterControls params={this.components} values={v} onChange={this.componentChange} onEnter={this.props.onEnter} />
  783. </div>}
  784. </>;
  785. }
  786. }
  787. export class UrlControl extends SimpleParam<PD.UrlParam> {
  788. onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  789. const value = e.target.value;
  790. if (value !== Asset.getUrl(this.props.value || '')) {
  791. this.update(Asset.Url(value));
  792. }
  793. };
  794. onKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
  795. if ((e.keyCode === 13 || e.charCode === 13 || e.key === 'Enter')) {
  796. if (this.props.onEnter) this.props.onEnter();
  797. }
  798. e.stopPropagation();
  799. };
  800. renderControl() {
  801. const placeholder = this.props.param.label || camelCaseToWords(this.props.name);
  802. return <input type='text'
  803. value={Asset.getUrl(this.props.value || '')}
  804. placeholder={placeholder}
  805. onChange={this.onChange}
  806. onKeyPress={this.props.onEnter ? this.onKeyPress : void 0}
  807. disabled={this.props.isDisabled}
  808. />;
  809. }
  810. }
  811. export class FileControl extends React.PureComponent<ParamProps<PD.FileParam>> {
  812. state = { showHelp: false };
  813. change(value: File) {
  814. this.props.onChange({ name: this.props.name, param: this.props.param, value: Asset.File(value) });
  815. }
  816. onChangeFile = (e: React.ChangeEvent<HTMLInputElement>) => {
  817. this.change(e.target.files![0]);
  818. };
  819. toggleHelp = () => this.setState({ showHelp: !this.state.showHelp });
  820. renderControl() {
  821. const value = this.props.value;
  822. return <div className='msp-btn msp-btn-block msp-btn-action msp-loader-msp-btn-file' style={{ marginTop: '1px' }}>
  823. {value ? value.name : 'Select a file...'} <input disabled={this.props.isDisabled} onChange={this.onChangeFile} type='file' multiple={false} accept={this.props.param.accept} />
  824. </div>;
  825. }
  826. render() {
  827. if (this.props.param.label) {
  828. return renderSimple({
  829. props: this.props,
  830. state: this.state,
  831. control: this.renderControl(),
  832. toggleHelp: this.toggleHelp,
  833. addOn: null
  834. });
  835. } else {
  836. return this.renderControl();
  837. }
  838. }
  839. }
  840. export class FileListControl extends React.PureComponent<ParamProps<PD.FileListParam>> {
  841. state = { showHelp: false };
  842. change(value: FileList) {
  843. const files: Asset.File[] = [];
  844. if (value) {
  845. for (let i = 0, il = value.length; i < il; ++i) {
  846. files.push(Asset.File(value[i]));
  847. }
  848. }
  849. this.props.onChange({ name: this.props.name, param: this.props.param, value: files });
  850. }
  851. onChangeFileList = (e: React.ChangeEvent<HTMLInputElement>) => {
  852. this.change(e.target.files!);
  853. };
  854. toggleHelp = () => this.setState({ showHelp: !this.state.showHelp });
  855. renderControl() {
  856. const value = this.props.value;
  857. const names: string[] = [];
  858. if (value) {
  859. for (const file of value) {
  860. names.push(file.name);
  861. }
  862. }
  863. const label = names.length === 0
  864. ? 'Select files...' : names.length === 1
  865. ? names[0] : `${names.length} files selected`;
  866. return <div className='msp-btn msp-btn-block msp-btn-action msp-loader-msp-btn-file' style={{ marginTop: '1px' }}>
  867. {label} <input disabled={this.props.isDisabled} onChange={this.onChangeFileList} type='file' multiple={true} accept={this.props.param.accept} />
  868. </div>;
  869. }
  870. render() {
  871. if (this.props.param.label) {
  872. return renderSimple({
  873. props: this.props,
  874. state: this.state,
  875. control: this.renderControl(),
  876. toggleHelp: this.toggleHelp,
  877. addOn: null
  878. });
  879. } else {
  880. return this.renderControl();
  881. }
  882. }
  883. }
  884. export class MultiSelectControl extends React.PureComponent<ParamProps<PD.MultiSelect<any>>, { isExpanded: boolean }> {
  885. state = { isExpanded: false };
  886. change(value: PD.MultiSelect<any>['defaultValue']) {
  887. this.props.onChange({ name: this.props.name, param: this.props.param, value });
  888. }
  889. toggle(key: string) {
  890. return (e: React.MouseEvent<HTMLButtonElement>) => {
  891. if (this.props.value.indexOf(key) < 0) this.change(this.props.value.concat(key));
  892. else this.change(this.props.value.filter(v => v !== key));
  893. e.currentTarget.blur();
  894. };
  895. }
  896. toggleExpanded = (e: React.MouseEvent<HTMLButtonElement>) => {
  897. this.setState({ isExpanded: !this.state.isExpanded });
  898. e.currentTarget.blur();
  899. };
  900. render() {
  901. const current = this.props.value;
  902. const emptyLabel = this.props.param.emptyValue;
  903. const label = this.props.param.label || camelCaseToWords(this.props.name);
  904. return <>
  905. <ControlRow label={label} control={<button onClick={this.toggleExpanded} disabled={this.props.isDisabled}>
  906. {current.length === 0 && emptyLabel ? emptyLabel : `${current.length} of ${this.props.param.options.length}`}
  907. </button>} />
  908. {this.state.isExpanded && <div className='msp-control-offset'>
  909. {this.props.param.options.map(([value, label]) => {
  910. const sel = current.indexOf(value) >= 0;
  911. return <Button key={value} onClick={this.toggle(value)} disabled={this.props.isDisabled} style={{ marginTop: '1px' }}>
  912. <span style={{ float: sel ? 'left' : 'right' }}>{sel ? `✓ ${label}` : `${label} ✗`}</span>
  913. </Button>;
  914. })}
  915. </div>}
  916. </>;
  917. }
  918. }
  919. export class GroupControl extends React.PureComponent<ParamProps<PD.Group<any>> & { inMapped?: boolean }, { isExpanded: boolean, showPresets: boolean, showHelp: boolean }> {
  920. state = { isExpanded: !!this.props.param.isExpanded, showPresets: false, showHelp: false };
  921. change(value: any) {
  922. this.props.onChange({ name: this.props.name, param: this.props.param, value });
  923. }
  924. onChangeParam: ParamOnChange = e => {
  925. this.change({ ...this.props.value, [e.name]: e.value });
  926. };
  927. toggleExpanded = () => this.setState({ isExpanded: !this.state.isExpanded });
  928. toggleShowPresets = () => this.setState({ showPresets: !this.state.showPresets });
  929. presetItems = memoizeLatest((param: PD.Group<any>) => ActionMenu.createItemsFromSelectOptions(param.presets ?? []));
  930. onSelectPreset: ActionMenu.OnSelect = item => {
  931. this.setState({ showPresets: false });
  932. this.change(item?.value);
  933. };
  934. pivotedPresets() {
  935. if (!this.props.param.presets) return null;
  936. const label = this.props.param.label || camelCaseToWords(this.props.name);
  937. return <div className='msp-control-group-wrapper'>
  938. <div className='msp-control-group-header'>
  939. <button className='msp-btn msp-form-control msp-btn-block' onClick={this.toggleShowPresets}>
  940. <Icon svg={BookmarksOutlinedSvg} />
  941. {label} Presets
  942. </button>
  943. </div>
  944. {this.state.showPresets && <ActionMenu items={this.presetItems(this.props.param)} onSelect={this.onSelectPreset} />}
  945. </div>;
  946. }
  947. presets() {
  948. if (!this.props.param.presets) return null;
  949. return <>
  950. <div className='msp-control-group-presets-wrapper'>
  951. <div className='msp-control-group-header'>
  952. <button className='msp-btn msp-form-control msp-btn-block' onClick={this.toggleShowPresets}>
  953. <Icon svg={BookmarksOutlinedSvg} />
  954. Presets
  955. </button>
  956. </div>
  957. </div>
  958. {this.state.showPresets && <ActionMenu items={this.presetItems(this.props.param)} onSelect={this.onSelectPreset} />}
  959. </>;
  960. }
  961. pivoted() {
  962. const key = this.props.param.pivot as string;
  963. const params = this.props.param.params;
  964. const pivot = params[key];
  965. const Control = controlFor(pivot)!;
  966. const ctrl = <Control name={key} param={pivot} value={this.props.value[key]} onChange={this.onChangeParam} onEnter={this.props.onEnter} isDisabled={this.props.isDisabled} />;
  967. if (!this.state.isExpanded) {
  968. return <div className='msp-mapped-parameter-group'>
  969. {ctrl}
  970. <IconButton svg={MoreHorizSvg} onClick={this.toggleExpanded} toggleState={this.state.isExpanded} title={`More Options`} />
  971. </div>;
  972. }
  973. const filtered = Object.create(null);
  974. for (const k of Object.keys(params)) {
  975. if (k !== key) filtered[k] = params[k];
  976. }
  977. return <div className='msp-mapped-parameter-group'>
  978. {ctrl}
  979. <IconButton svg={MoreHorizSvg} onClick={this.toggleExpanded} toggleState={this.state.isExpanded} title={`More Options`} />
  980. <div className='msp-control-offset'>
  981. {this.pivotedPresets()}
  982. <ParameterControls params={filtered} onEnter={this.props.onEnter} values={this.props.value} onChange={this.onChangeParam} isDisabled={this.props.isDisabled} />
  983. </div>
  984. </div>;
  985. }
  986. render() {
  987. const params = this.props.param.params;
  988. // Do not show if there are no params.
  989. if (Object.keys(params).length === 0) return null;
  990. if (this.props.param.pivot) return this.pivoted();
  991. const label = this.props.param.label || camelCaseToWords(this.props.name);
  992. const controls = <ParameterControls params={params} onChange={this.onChangeParam} values={this.props.value} onEnter={this.props.onEnter} isDisabled={this.props.isDisabled} />;
  993. if (this.props.inMapped) {
  994. return <div className='msp-control-offset'>{controls}</div>;
  995. }
  996. if (this.props.param.isFlat) {
  997. return controls;
  998. }
  999. return <div className='msp-control-group-wrapper' style={{ position: 'relative' }}>
  1000. <div className='msp-control-group-header'>
  1001. <button className='msp-btn msp-form-control msp-btn-block' onClick={this.toggleExpanded}>
  1002. <Icon svg={this.state.isExpanded ? ArrowDropDownSvg : ArrowRightSvg} />
  1003. {label}
  1004. </button>
  1005. </div>
  1006. {this.presets()}
  1007. {this.state.isExpanded && <div className='msp-control-offset'>
  1008. {controls}
  1009. </div>}
  1010. </div>;
  1011. }
  1012. }
  1013. export class MappedControl extends React.PureComponent<ParamProps<PD.Mapped<any>>, { isExpanded: boolean }> {
  1014. state = { isExpanded: false };
  1015. // TODO: this could lead to a rare bug where the component is reused with different mapped control.
  1016. // I think there are currently no cases where this could happen in the UI, but still need to watch out..
  1017. private valuesCache: { [name: string]: PD.Values<any> } = {};
  1018. private setValues(name: string, values: PD.Values<any>) {
  1019. this.valuesCache[name] = values;
  1020. }
  1021. private getValues(name: string) {
  1022. if (name in this.valuesCache) {
  1023. return this.valuesCache[name];
  1024. } else {
  1025. return this.props.param.map(name).defaultValue;
  1026. }
  1027. }
  1028. change(value: PD.Mapped<any>['defaultValue']) {
  1029. this.props.onChange({ name: this.props.name, param: this.props.param, value });
  1030. }
  1031. onChangeName: ParamOnChange = e => {
  1032. this.change({ name: e.value, params: this.getValues(e.value) });
  1033. };
  1034. onChangeParam: ParamOnChange = e => {
  1035. this.setValues(this.props.value.name, e.value);
  1036. this.change({ name: this.props.value.name, params: e.value });
  1037. };
  1038. toggleExpanded = () => this.setState({ isExpanded: !this.state.isExpanded });
  1039. areParamsEmpty(params: PD.Params) {
  1040. for (const k of Object.keys(params)) {
  1041. if (!params[k].isHidden) return false;
  1042. }
  1043. return true;
  1044. }
  1045. render() {
  1046. const value: PD.Mapped<any>['defaultValue'] = this.props.value || this.props.param.defaultValue;
  1047. const param = this.props.param.map(value.name);
  1048. const label = this.props.param.label || camelCaseToWords(this.props.name);
  1049. const Mapped = controlFor(param);
  1050. const help = this.props.param.help;
  1051. const select = help
  1052. ? {
  1053. ...this.props.param.select,
  1054. help: (name: any) => help({ name, params: this.getValues(name) })
  1055. }
  1056. : this.props.param.select;
  1057. const Select = <SelectControl param={select}
  1058. isDisabled={this.props.isDisabled} onChange={this.onChangeName} onEnter={this.props.onEnter}
  1059. name={label} value={value.name} />;
  1060. if (!Mapped) {
  1061. return Select;
  1062. }
  1063. if (param.type === 'group' && !param.isFlat) {
  1064. if (!this.areParamsEmpty(param.params)) {
  1065. return <div className='msp-mapped-parameter-group'>
  1066. {Select}
  1067. <IconButton svg={MoreHorizSvg} onClick={this.toggleExpanded} toggleState={this.state.isExpanded} title={`${label} Properties`} />
  1068. {this.state.isExpanded && <GroupControl inMapped param={param} value={value.params} name={value.name} onChange={this.onChangeParam} onEnter={this.props.onEnter} isDisabled={this.props.isDisabled} />}
  1069. </div>;
  1070. }
  1071. return Select;
  1072. }
  1073. return <>
  1074. {Select}
  1075. <Mapped param={param} value={value.params} name={value.name} onChange={this.onChangeParam} onEnter={this.props.onEnter} isDisabled={this.props.isDisabled} />
  1076. </>;
  1077. }
  1078. }
  1079. type ObjectListEditorProps = { params: PD.Params, value: object, isUpdate?: boolean, apply: (value: any) => void, isDisabled?: boolean }
  1080. class ObjectListEditor extends React.PureComponent<ObjectListEditorProps, { current: object }> {
  1081. state = { current: this.props.value };
  1082. onChangeParam: ParamOnChange = e => {
  1083. this.setState({ current: { ...this.state.current, [e.name]: e.value } });
  1084. };
  1085. apply = () => {
  1086. this.props.apply(this.state.current);
  1087. };
  1088. componentDidUpdate(prevProps: ObjectListEditorProps) {
  1089. if (this.props.params !== prevProps.params || this.props.value !== prevProps.value) {
  1090. this.setState({ current: this.props.value });
  1091. }
  1092. }
  1093. render() {
  1094. return <>
  1095. <ParameterControls params={this.props.params} onChange={this.onChangeParam} values={this.state.current} onEnter={this.apply} isDisabled={this.props.isDisabled} />
  1096. <button className={`msp-btn msp-btn-block msp-form-control msp-control-top-offset`} onClick={this.apply} disabled={this.props.isDisabled}>
  1097. {this.props.isUpdate ? 'Update' : 'Add'}
  1098. </button>
  1099. </>;
  1100. }
  1101. }
  1102. type ObjectListItemProps = { param: PD.ObjectList, value: object, index: number, actions: ObjectListControl['actions'], isDisabled?: boolean }
  1103. class ObjectListItem extends React.PureComponent<ObjectListItemProps, { isExpanded: boolean }> {
  1104. state = { isExpanded: false };
  1105. update = (v: object) => {
  1106. // this.setState({ isExpanded: false }); // TODO auto update? mark changed state?
  1107. this.props.actions.update(v, this.props.index);
  1108. };
  1109. moveUp = () => {
  1110. this.props.actions.move(this.props.index, -1);
  1111. };
  1112. moveDown = () => {
  1113. this.props.actions.move(this.props.index, 1);
  1114. };
  1115. remove = () => {
  1116. this.setState({ isExpanded: false });
  1117. this.props.actions.remove(this.props.index);
  1118. };
  1119. toggleExpanded = (e: React.MouseEvent<HTMLButtonElement>) => {
  1120. this.setState({ isExpanded: !this.state.isExpanded });
  1121. e.currentTarget.blur();
  1122. };
  1123. render() {
  1124. return <>
  1125. <div className='msp-param-object-list-item'>
  1126. <button className='msp-btn msp-btn-block msp-form-control' onClick={this.toggleExpanded}>
  1127. <span>{`${this.props.index + 1}: `}</span>
  1128. {this.props.param.getLabel(this.props.value)}
  1129. </button>
  1130. <div>
  1131. <IconButton svg={ArrowDownwardSvg} title='Move Up' onClick={this.moveUp} small={true} />
  1132. <IconButton svg={ArrowUpwardSvg} title='Move Down' onClick={this.moveDown} small={true} />
  1133. <IconButton svg={DeleteOutlinedSvg} title='Remove' onClick={this.remove} small={true} />
  1134. </div>
  1135. </div>
  1136. {this.state.isExpanded && <div className='msp-control-offset'>
  1137. <ObjectListEditor params={this.props.param.element} apply={this.update} value={this.props.value} isUpdate isDisabled={this.props.isDisabled} />
  1138. </div>}
  1139. </>;
  1140. }
  1141. }
  1142. export class ObjectListControl extends React.PureComponent<ParamProps<PD.ObjectList>, { isExpanded: boolean }> {
  1143. state = { isExpanded: false };
  1144. change(value: any) {
  1145. this.props.onChange({ name: this.props.name, param: this.props.param, value });
  1146. }
  1147. add = (v: object) => {
  1148. this.change([...this.props.value, v]);
  1149. };
  1150. actions = {
  1151. update: (v: object, i: number) => {
  1152. const value = this.props.value.slice(0);
  1153. value[i] = v;
  1154. this.change(value);
  1155. },
  1156. move: (i: number, dir: -1 | 1) => {
  1157. let xs = this.props.value;
  1158. if (xs.length === 1) return;
  1159. let j = (i + dir) % xs.length;
  1160. if (j < 0) j += xs.length;
  1161. xs = xs.slice(0);
  1162. const t = xs[i];
  1163. xs[i] = xs[j];
  1164. xs[j] = t;
  1165. this.change(xs);
  1166. },
  1167. remove: (i: number) => {
  1168. const xs = this.props.value;
  1169. const update: object[] = [];
  1170. for (let j = 0; j < xs.length; j++) {
  1171. if (i !== j) update.push(xs[j]);
  1172. }
  1173. this.change(update);
  1174. }
  1175. };
  1176. toggleExpanded = (e: React.MouseEvent<HTMLButtonElement>) => {
  1177. this.setState({ isExpanded: !this.state.isExpanded });
  1178. e.currentTarget.blur();
  1179. };
  1180. render() {
  1181. const v = this.props.value;
  1182. const label = this.props.param.label || camelCaseToWords(this.props.name);
  1183. const value = `${v.length} item${v.length !== 1 ? 's' : ''}`;
  1184. return <>
  1185. <ControlRow label={label} control={<button onClick={this.toggleExpanded} disabled={this.props.isDisabled}>{value}</button>} />
  1186. {this.state.isExpanded && <div className='msp-control-offset'>
  1187. {this.props.value.map((v, i) => <ObjectListItem key={i} param={this.props.param} value={v} index={i} actions={this.actions} isDisabled={this.props.isDisabled} />)}
  1188. <ControlGroup header='New Item'>
  1189. <ObjectListEditor params={this.props.param.element} apply={this.add} value={this.props.param.ctor()} isDisabled={this.props.isDisabled} />
  1190. </ControlGroup>
  1191. </div>}
  1192. </>;
  1193. }
  1194. }
  1195. export class ConditionedControl extends React.PureComponent<ParamProps<PD.Conditioned<any, any, any>>> {
  1196. change(value: PD.Conditioned<any, any, any>['defaultValue']) {
  1197. this.props.onChange({ name: this.props.name, param: this.props.param, value });
  1198. }
  1199. onChangeCondition: ParamOnChange = e => {
  1200. this.change(this.props.param.conditionedValue(this.props.value, e.value));
  1201. };
  1202. onChangeParam: ParamOnChange = e => {
  1203. this.change(e.value);
  1204. };
  1205. render() {
  1206. const value = this.props.value;
  1207. const condition = this.props.param.conditionForValue(value) as string;
  1208. const param = this.props.param.conditionParams[condition];
  1209. const label = this.props.param.label || camelCaseToWords(this.props.name);
  1210. const Conditioned = controlFor(param);
  1211. const select = <SelectControl param={this.props.param.select}
  1212. isDisabled={this.props.isDisabled} onChange={this.onChangeCondition} onEnter={this.props.onEnter}
  1213. name={`${label} Kind`} value={condition} />;
  1214. if (!Conditioned) {
  1215. return select;
  1216. }
  1217. return <>
  1218. {select}
  1219. <Conditioned param={param} value={value} name={label} onChange={this.onChangeParam} onEnter={this.props.onEnter} isDisabled={this.props.isDisabled} />
  1220. </>;
  1221. }
  1222. }
  1223. export class ConvertedControl extends React.PureComponent<ParamProps<PD.Converted<any, any>>> {
  1224. onChange: ParamOnChange = e => {
  1225. this.props.onChange({
  1226. name: this.props.name,
  1227. param: this.props.param,
  1228. value: this.props.param.toValue(e.value)
  1229. });
  1230. };
  1231. render() {
  1232. const value = this.props.param.fromValue(this.props.value);
  1233. const Converted = controlFor(this.props.param.converted);
  1234. if (!Converted) return null;
  1235. return <Converted param={this.props.param.converted} value={value} name={this.props.name} onChange={this.onChange} onEnter={this.props.onEnter} isDisabled={this.props.isDisabled} />;
  1236. }
  1237. }
  1238. export class ScriptControl extends React.PureComponent<ParamProps<PD.Script>> {
  1239. onChange: ParamOnChange = ({ name, value }) => {
  1240. const k = name as 'language' | 'expression';
  1241. if (value !== this.props.value[k]) {
  1242. this.props.onChange({ param: this.props.param, name: this.props.name, value: { ...this.props.value, [k]: value } });
  1243. }
  1244. };
  1245. render() {
  1246. // TODO: improve!
  1247. const selectParam: PD.Select<PD.Script['defaultValue']['language']> = {
  1248. defaultValue: this.props.value.language,
  1249. options: PD.objectToOptions(Script.Info),
  1250. type: 'select',
  1251. };
  1252. const select = <SelectControl param={selectParam}
  1253. isDisabled={this.props.isDisabled} onChange={this.onChange} onEnter={this.props.onEnter}
  1254. name='language' value={this.props.value.language} />;
  1255. const textParam: PD.Text = {
  1256. defaultValue: this.props.value.language,
  1257. type: 'text',
  1258. };
  1259. const text = <TextControl param={textParam} isDisabled={this.props.isDisabled} onChange={this.onChange} name='expression' value={this.props.value.expression} />;
  1260. return <>
  1261. {select}
  1262. {this.props.value.language !== 'mol-script' && <div className='msp-help-text' style={{ padding: '10px' }}>
  1263. <Icon svg={WarningSvg} /> Support for PyMOL, VMD, and Jmol selections is an experimental feature and may not always work as intended.
  1264. </div>}
  1265. {text}
  1266. </>;
  1267. }
  1268. }