diff --git a/src/mol-plugin-ui/base.tsx b/src/mol-plugin-ui/base.tsx index d377417addc71ca14c8c4eec0c0cbccd37cdcc07..38d3977f1b6eecf3d819ab8e038ef5ea36cf048c 100644 --- a/src/mol-plugin-ui/base.tsx +++ b/src/mol-plugin-ui/base.tsx @@ -25,6 +25,7 @@ export abstract class PluginUIComponent<P = {}, S = {}, SS = {}> extends React.C componentWillUnmount() { if (!this.subs) return; for (const s of this.subs) s.unsubscribe(); + this.subs = []; } protected init?(): void; diff --git a/src/mol-plugin-ui/controls/action-menu.tsx b/src/mol-plugin-ui/controls/action-menu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dcdb704b681a9f6844eda4b9f501b681b7975568 --- /dev/null +++ b/src/mol-plugin-ui/controls/action-menu.tsx @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author David Sehnal <david.sehnal@gmail.com> + */ + +import * as React from 'react' +import { Icon } from './common'; +import { Observable, Subscription } from 'rxjs'; + +export namespace ActionMenu { + export class Options extends React.PureComponent<{ toggle: Observable<OptionsParams | undefined>, hide?: Observable<any> }, { options: OptionsParams | undefined, isVisible: boolean }> { + private subs: Subscription[] = []; + + state = { isVisible: false, options: void 0 as OptionsParams | undefined } + + componentDidMount() { + this.subs.push(this.props.toggle.subscribe(options => { + if (options && this.state.options?.items === options.items && this.state.options?.onSelect === options.onSelect) { + this.setState({ isVisible: !this.state.isVisible}); + } else { + this.setState({ isVisible: !!options, options: options }) + } + })); + + if (this.props.hide) { + this.subs.push(this.props.hide.subscribe(() => this.hide())); + } + } + + componentWillUnmount() { + if (!this.subs) return; + for (const s of this.subs) s.unsubscribe(); + this.subs = []; + } + + onSelect: OnSelect = item => { + this.setState({ isVisible: false, options: void 0 }); + this.state.options?.onSelect(item.value); + } + + hide = () => this.setState({ isVisible: false, options: void 0 }); + + render() { + if (!this.state.isVisible || !this.state.options) return null; + return <div className='msp-action-menu-options'> + {this.state.options.header && <div className='msp-control-group-header'> + <button className='msp-btn msp-btn-block' onClick={this.hide}> + {this.state.options.header} + </button> + </div>} + <Section items={this.state.options!.items} onSelect={this.onSelect} /> + </div> + } + } + + class Section extends React.PureComponent<{ header?: string, items: Spec, onSelect: OnSelect }, { isExpanded: boolean }> { + state = { isExpanded: false } + + toggleExpanded = (e: React.MouseEvent<HTMLButtonElement>) => { + this.setState({ isExpanded: !this.state.isExpanded }); + e.currentTarget.blur(); + } + + render() { + const { header, items, onSelect } = this.props; + if (typeof items === 'string') return null; + if (isItem(items)) return <Action item={items} onSelect={onSelect} /> + return <div className='msp-control-offset'> + {header && <div className='msp-control-group-header'> + <button className='msp-btn msp-btn-block' onClick={this.toggleExpanded}> + <span className={`msp-icon msp-icon-${this.state.isExpanded ? 'collapse' : 'expand'}`} /> + {header} + </button> + </div>} + {(!header || this.state.isExpanded) && items.map((x, i) => { + if (typeof x === 'string') return null; + if (isItem(x)) return <Action key={i} item={x} onSelect={onSelect} /> + return <Section key={i} header={typeof x[0] === 'string' ? x[0] : void 0} items={x} onSelect={onSelect} /> + })} + </div>; + } + } + + const Action: React.FC<{ item: Item, onSelect: OnSelect }> = ({ item, onSelect }) => { + return <div className='msp-control-row'> + <button onClick={() => onSelect(item)}> + {item.icon && <Icon name={item.icon} />} + {item.name} + </button> + </div>; + } + + type OnSelect = (item: Item) => void + + function isItem(x: any): x is Item { + const v = x as Item; + return v && !!v.name && typeof v.value !== 'undefined'; + } + + export type OptionsParams = { items: Spec, header?: string, onSelect: (value: any) => void } + export type Spec = string | Item | [Spec] + export type Item = { name: string, icon?: string, value: unknown } + + export function Item(name: string, value: unknown): Item + export function Item(name: string, icon: string, value: unknown): Item + export function Item(name: string, iconOrValue: any, value?: unknown): Item { + if (value) return { name, icon: iconOrValue, value }; + return { name, value: iconOrValue }; + } +} \ No newline at end of file diff --git a/src/mol-plugin-ui/controls/parameters.tsx b/src/mol-plugin-ui/controls/parameters.tsx index c4807eaa5f853cc72f3b311f1afb4aef5cffd85c..6efe77704e2f26679b40b7eec99bb8ca78df7afa 100644 --- a/src/mol-plugin-ui/controls/parameters.tsx +++ b/src/mol-plugin-ui/controls/parameters.tsx @@ -326,6 +326,7 @@ export class SelectControl extends SimpleParam<PD.Select<string | number>> { this.update(e.target.value); } } + renderControl() { const isInvalid = this.props.value !== void 0 && !this.props.param.options.some(e => e[0] === this.props.value); return <select value={this.props.value !== void 0 ? this.props.value : this.props.param.defaultValue} onChange={this.onChange} disabled={this.props.isDisabled}> diff --git a/src/mol-plugin-ui/skin/base/components/temp.scss b/src/mol-plugin-ui/skin/base/components/temp.scss index 2c13c618effd0c51dd51a34edbe58c3315585cef..b614362ecbc4390fb85adbda052cbabca7ee8fc4 100644 --- a/src/mol-plugin-ui/skin/base/components/temp.scss +++ b/src/mol-plugin-ui/skin/base/components/temp.scss @@ -275,4 +275,34 @@ .msp-transform-wrapper:last-child { margin-bottom: 10px; } +} + +.msp-button-row { + display:flex; + flex-direction:row; + height: $row-height; + width: inherit; + + > button { + margin: 0; + flex: 1 1 auto; + margin-right: 1px; + height: $row-height; + + text-align-last: center; + background: none; + padding: 0 $control-spacing; + overflow: hidden; + } +} + +.msp-action-menu-options { + .msp-control-row, button, .msp-icon { + height: 24px; + line-height: 24px; + } + + button { + text-align: left; + } } \ No newline at end of file diff --git a/src/mol-plugin-ui/structure/selection.tsx b/src/mol-plugin-ui/structure/selection.tsx index 8847ccc0b9e8154c62ce29936bed0e2e6c4bcae3..6011b09dfb616f4c6908553f3403821bbb5ead3c 100644 --- a/src/mol-plugin-ui/structure/selection.tsx +++ b/src/mol-plugin-ui/structure/selection.tsx @@ -7,13 +7,14 @@ import * as React from 'react'; import { CollapsableControls, CollapsableState } from '../base'; import { StructureSelectionQueries, SelectionModifier } from '../../mol-plugin/util/structure-selection-helper'; -import { ButtonSelect, Options } from '../controls/common'; import { PluginCommands } from '../../mol-plugin/command'; import { ParamDefinition as PD } from '../../mol-util/param-definition'; import { Interactivity } from '../../mol-plugin/util/interactivity'; import { ParameterControls } from '../controls/parameters'; import { stripTags } from '../../mol-util/string'; import { StructureElement } from '../../mol-model/structure'; +import { ActionMenu } from '../controls/action-menu'; +import { Subject } from 'rxjs'; const SSQ = StructureSelectionQueries const DefaultQueries: (keyof typeof SSQ)[] = [ @@ -124,28 +125,19 @@ export class StructureSelectionControls<P, S extends StructureSelectionControlsS remove = (value: string) => this.set('remove', value) only = (value: string) => this.set('only', value) - queries = Options(Object.keys(StructureSelectionQueries) - .map(name => [name, SSQ[name as keyof typeof SSQ].label] as [string, string]) - .filter(pair => DefaultQueries.includes(pair[0] as keyof typeof SSQ))); - - controls = <div className='msp-control-row'> - <div className='msp-select-row'> - <ButtonSelect label='Select' onChange={this.add} disabled={this.state.isDisabled}> - <optgroup label='Select'> - {this.queries} - </optgroup> - </ButtonSelect> - <ButtonSelect label='Deselect' onChange={this.remove} disabled={this.state.isDisabled}> - <optgroup label='Deselect'> - {this.queries} - </optgroup> - </ButtonSelect> - <ButtonSelect label='Only' onChange={this.only} disabled={this.state.isDisabled}> - <optgroup label='Only'> - {this.queries} - </optgroup> - </ButtonSelect> + queries = Object.keys(StructureSelectionQueries) + .map(name => ActionMenu.Item(SSQ[name as keyof typeof SSQ].label, name)) + .filter(item => DefaultQueries.includes(item.value as keyof typeof SSQ)) as ActionMenu.Spec; + + actionMenu = new Subject<ActionMenu.OptionsParams | undefined>(); + + controls = <div> + <div className='msp-control-row msp-button-row' style={{ marginBottom: '1px' }}> + <button onClick={() => this.actionMenu.next({ items: this.queries, header: 'Select', onSelect: this.add }) } disabled={this.state.isDisabled}>Select</button> + <button onClick={() => this.actionMenu.next({ items: this.queries, header: 'Deselect', onSelect: this.remove }) } disabled={this.state.isDisabled}>Deselect</button> + <button onClick={() => this.actionMenu.next({ items: this.queries, header: 'Only', onSelect: this.only }) } disabled={this.state.isDisabled}>Only</button> </div> + <ActionMenu.Options toggle={this.actionMenu} hide={this.plugin.state.dataState.events.isUpdating} /> </div> defaultState() {