diff --git a/src/mol-plugin/ui/controls/color.tsx b/src/mol-plugin/ui/controls/color.tsx index 46833126c61275d1f1fdea97972cd9f98a6ecadb..5fc8c810bddc193e9d82bf5c22a94152b62a437e 100644 --- a/src/mol-plugin/ui/controls/color.tsx +++ b/src/mol-plugin/ui/controls/color.tsx @@ -11,6 +11,7 @@ import { camelCaseToWords } from '../../../mol-util/string'; import * as React from 'react'; import { _Props, _State } from '../base'; import { ParamProps } from './parameters'; +import { TextInput } from './common'; export class CombinedColorControl extends React.PureComponent<ParamProps<PD.Color>, { isExpanded: boolean }> { state = { isExpanded: false } @@ -38,6 +39,12 @@ export class CombinedColorControl extends React.PureComponent<ParamProps<PD.Colo } } + onChangeText = (value: Color) => { + if (value !== this.props.value) { + this.update(value); + } + } + swatch() { // const def = this.props.param.defaultValue; return <div className='msp-combined-color-swatch'> @@ -46,25 +53,6 @@ export class CombinedColorControl extends React.PureComponent<ParamProps<PD.Colo </div>; } - // TODO: include text options as well? - // onChangeText = () => {}; - // text() { - // const [r, g, b] = Color.toRgb(this.props.value); - // return <input type='text' - // value={`${r} ${g} ${b}`} - // placeholder={'Red Green Blue'} - // onChange={this.onChangeText} - // onKeyPress={this.props.onEnter ? this.onKeyPress : void 0} - // disabled={this.props.isDisabled} - // />; - // } - // onKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => { - // if (!this.props.onEnter) return; - // if ((e.keyCode === 13 || e.charCode === 13)) { - // this.props.onEnter(); - // } - // } - stripStyle(): React.CSSProperties { return { background: Color.toStyle(this.props.value), @@ -76,7 +64,6 @@ export class CombinedColorControl extends React.PureComponent<ParamProps<PD.Colo }; } - render() { const label = this.props.param.label || camelCaseToWords(this.props.name); return <> @@ -89,7 +76,17 @@ export class CombinedColorControl extends React.PureComponent<ParamProps<PD.Colo {this.state.isExpanded && <div className='msp-control-offset'> {this.swatch()} <div className='msp-control-row'> - <div style={{ position: 'relative' }}> + <span>RGB</span> + <div> + <TextInput onChange={this.onChangeText} value={this.props.value} + fromValue={formatColorRGB} toValue={getColorFromString} isValid={isValidColorString} + className='msp-form-control' onEnter={this.props.onEnter} blurOnEnter={true} blurOnEscape={true} + placeholder='e.g. 127 127 127' delayMs={250} /> + </div> + </div> + <div className='msp-control-row'> + <span>Color List</span> + <div> <select value={this.props.value} onChange={this.onChangeSelect}> {ColorValueOption(this.props.value)} {ColorOptions()} @@ -102,6 +99,28 @@ export class CombinedColorControl extends React.PureComponent<ParamProps<PD.Colo } } +function formatColorRGB(c: Color) { + const [r, g, b] = Color.toRgb(c); + return `${r} ${g} ${b}`; +} + +function getColorFromString(s: string) { + const cs = s.split(/\s+/g); + return Color.fromRgb(+cs[0], +cs[1], +cs[2]); +} + +function isValidColorString(s: string) { + const cs = s.split(/\s+/g); + if (cs.length !== 3 && !(cs.length === 4 && cs[3] === '')) return false; + for (const c of cs) { + if (c === '') continue; + const n = +c; + if ('' + n !== c) return false; + if (n < 0 || n > 255) return false; + } + return true; +} + // the 1st color is the default value. const SwatchColors = [ 0x000000, 0x808080, 0xFFFFFF, 0xD33115, 0xE27300, 0xFCC400, diff --git a/src/mol-plugin/ui/controls/common.tsx b/src/mol-plugin/ui/controls/common.tsx index 46ca5ba4d38b675c140ce0285cb37f8a0cb6d724..f05f77f1b7a369dd8b2f20ad7dfd60554c527f7c 100644 --- a/src/mol-plugin/ui/controls/common.tsx +++ b/src/mol-plugin/ui/controls/common.tsx @@ -6,6 +6,7 @@ import * as React from 'react'; import { Color } from '../../../mol-util/color'; +import { PurePluginUIComponent } from '../base'; export class ControlGroup extends React.Component<{ header: string, initialExpanded?: boolean }, { isExpanded: boolean }> { state = { isExpanded: !!this.props.initialExpanded } @@ -28,6 +29,128 @@ export class ControlGroup extends React.Component<{ header: string, initialExpan } } +export interface TextInputProps<T> { + className?: string, + style?: React.CSSProperties, + value: T, + fromValue?(v: T): string, + toValue?(s: string): T, + // TODO: add error/help messages here? + isValid?(s: string): boolean, + onChange(value: T): void, + onEnter?(): void, + onBlur?(): void, + delayMs?: number, + blurOnEnter?: boolean, + blurOnEscape?: boolean, + isDisabled?: boolean, + placeholder?: string +} + +interface TextInputState { + originalValue: string, + value: string +} + +function _id(x: any) { return x; } + +export class TextInput<T = string> extends PurePluginUIComponent<TextInputProps<T>, TextInputState> { + private input = React.createRef<HTMLInputElement>(); + private delayHandle: any = void 0; + private pendingValue: T | undefined = void 0; + + state = { originalValue: '', value: '' } + + onBlur = () => { + this.setState({ value: '' + this.state.originalValue }); + if (this.props.onBlur) this.props.onBlur(); + } + + get isPending() { return typeof this.delayHandle !== 'undefined'; } + + clearTimeout() { + if (this.isPending) { + clearTimeout(this.delayHandle); + this.delayHandle = void 0; + } + } + + raiseOnChange = () => { + this.props.onChange(this.pendingValue!); + this.pendingValue = void 0; + } + + triggerChanged(formatted: string, converted: T) { + this.clearTimeout(); + + if (formatted === this.state.originalValue) return; + + if (this.props.delayMs) { + this.pendingValue = converted; + this.delayHandle = setTimeout(this.raiseOnChange, this.props.delayMs); + } else { + this.props.onChange(converted); + } + } + + onChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const value = e.target.value; + + if (this.props.isValid && !this.props.isValid(value)) { + this.clearTimeout(); + this.setState({ value }); + return; + } + + const converted = (this.props.toValue || _id)(value); + const formatted = (this.props.fromValue || _id)(converted); + this.setState({ value: formatted }, () => this.triggerChanged(formatted, converted)); + } + + onKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.charCode === 27 || e.keyCode === 27 /* esc */) { + if (this.props.blurOnEscape && this.input.current) { + this.input.current.blur(); + } + } + } + + onKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.keyCode === 13 || e.charCode === 13 /* enter */) { + if (this.isPending) { + this.clearTimeout(); + this.raiseOnChange(); + } + if (this.props.blurOnEnter && this.input.current) { + this.input.current.blur(); + } + if (this.props.onEnter) this.props.onEnter(); + } + } + + static getDerivedStateFromProps(props: TextInputProps<any>, state: TextInputState) { + const value = props.fromValue ? props.fromValue(props.value) : props.value; + if (value === state.originalValue) return null; + return { originalValue: value, value }; + } + + render() { + return <input type='text' + className={this.props.className} + style={this.props.style} + ref={this.input} + onBlur={this.onBlur} + value={this.state.value} + placeholder={this.props.placeholder} + onChange={this.onChange} + onKeyPress={this.props.onEnter || this.props.blurOnEnter || this.props.blurOnEscape ? this.onKeyPress : void 0} + onKeyDown={this.props.blurOnEscape ? this.onKeyUp : void 0} + disabled={!!this.props.isDisabled} + />; + } +} + +// TODO: replace this with parametrized TextInput export class NumericInput extends React.PureComponent<{ value: number, onChange: (v: number) => void,