/** * 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 { Subscription, BehaviorSubject, Observable } from 'rxjs'; import { ParamDefinition } from '../../mol-util/param-definition'; export class ActionMenu { private _command: BehaviorSubject<ActionMenu.Command>; get commands(): Observable<ActionMenu.Command> { return this._command; } hide() { this._command.next(HideCmd) } toggle(params: { items: ActionMenu.Spec, header?: string, current?: ActionMenu.Item, onSelect: (value: any) => void }) { this._command.next({ type: 'toggle', ...params }); } constructor(defaultCommand?: ActionMenu.Command) { this._command = new BehaviorSubject<ActionMenu.Command>(defaultCommand || { type: 'hide' }); } } const HideCmd: ActionMenu.Command = { type: 'hide' }; export namespace ActionMenu { export type Command = | { type: 'toggle', items: Spec, header?: string, current?: Item, onSelect: (value: any) => void } | { type: 'hide' } function isToggleOff(a: Command, b: Command) { if (a.type === 'hide' || b.type === 'hide') return false; return a.onSelect === b.onSelect && a.items === b.items; } export type ToggleProps = { style?: React.CSSProperties, className?: string, menu: ActionMenu, disabled?: boolean, items: ActionMenu.Spec, header?: string, label?: string, current?: Item, onSelect: (value: any) => void } type ToggleState = { current?: Item, isSelected: boolean } export class Toggle extends React.PureComponent<ToggleProps, ToggleState> { private sub: Subscription | undefined = void 0; state = { isSelected: false, current: this.props.current }; componentDidMount() { this.sub = this.props.menu.commands.subscribe(command => { if (command.type === 'hide') { this.hide(); } else if (command.type === 'toggle') { const cmd = this.props; if (command.items === cmd.items && command.onSelect === cmd.onSelect) { this.setState({ isSelected: !this.state.isSelected }); } else { this.hide(); } } }); } componentWillUnmount() { if (!this.sub) return; this.sub.unsubscribe(); this.sub = void 0; } hide = () => this.setState({ isSelected: false }); onClick = (e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.blur(); this.props.menu.toggle(this.props); } static getDerivedStateFromProps(props: ToggleProps, state: ToggleState) { if (props.current === state.current) return null; return { isSelected: false, current: props.current }; } render() { const props = this.props; const label = props.label || props.header; return <button onClick={this.onClick} disabled={props.disabled} style={props.style} className={props.className}> {this.state.isSelected ? <b>{label}</b> : label} </button>; } } type OptionsProps = { menu: ActionMenu, header?: string, items?: Spec, current?: Item | undefined } export class Options extends React.PureComponent<OptionsProps, { command: Command, isVisible: boolean }> { private sub: Subscription | undefined = void 0; state = { isVisible: false, command: HideCmd }; componentDidMount() { this.sub = this.props.menu.commands.subscribe(command => { if (command.type === 'hide' || isToggleOff(command, this.state.command)) { this.setState({ isVisible: false, command: HideCmd }); } else { this.setState({ isVisible: true, command }) } }); } componentWillUnmount() { if (!this.sub) return; this.sub.unsubscribe(); this.sub = void 0; } onSelect: OnSelect = item => { const cmd = this.state.command; this.hide(); if (cmd.type === 'toggle') cmd.onSelect(item.value); } hide = () => { this.props.menu.hide(); } render() { const cmd = this.state.command; if (!this.state.isVisible || cmd.type !== 'toggle') return null; if (this.props.items) { if (cmd.items !== this.props.items || cmd.current !== this.props.current) return null; } return <div className='msp-action-menu-options' style={{ marginTop: '1px' }}> {cmd.header && <div className='msp-control-group-header' style={{ position: 'relative' }}> <button className='msp-btn msp-btn-block' onClick={this.hide}> <Icon name='off' style={{ position: 'absolute', right: '2px', top: 0 }} /> <b>{cmd.header}</b> </button> </div>} <Section menu={this.props.menu} items={cmd.items} onSelect={this.onSelect} current={cmd.current} /> </div> } } type SectionProps = { menu: ActionMenu, header?: string, items: Spec, onSelect: OnSelect, current: Item | undefined } type SectionState = { items: Spec, current: Item | undefined, isExpanded: boolean } class Section extends React.PureComponent<SectionProps, SectionState> { state = { items: this.props.items, current: this.props.current, isExpanded: !!this.props.current && !!findCurrent(this.props.items, this.props.current.value) } toggleExpanded = (e: React.MouseEvent<HTMLButtonElement>) => { this.setState({ isExpanded: !this.state.isExpanded }); e.currentTarget.blur(); } static getDerivedStateFromProps(props: SectionProps, state: SectionState) { if (props.items === state.items && props.current === state.current) return null; return { items: props.items, current: props.current, isExpanded: props.current && !!findCurrent(props.items, props.current.value) } } render() { const { header, items, onSelect, current, menu } = this.props; if (typeof items === 'string') return null; if (isItem(items)) return <Action menu={menu} item={items} onSelect={onSelect} current={current} /> const hasCurrent = header && current && !!findCurrent(items, current.value) return <div> {header && <div className='msp-control-group-header' style={{ marginTop: '1px' }}> <button className='msp-btn msp-btn-block' onClick={this.toggleExpanded}> <span className={`msp-icon msp-icon-${this.state.isExpanded ? 'collapse' : 'expand'}`} /> {hasCurrent ? <b>{header}</b> : header} </button> </div>} <div className='msp-control-offset'> {(!header || this.state.isExpanded) && items.map((x, i) => { if (typeof x === 'string') return null; if (isItem(x)) return <Action menu={menu} key={i} item={x} onSelect={onSelect} current={current} /> return <Section menu={menu} key={i} header={typeof x[0] === 'string' ? x[0] : void 0} items={x} onSelect={onSelect} current={current} /> })} </div> </div>; } } const Action: React.FC<{ menu: ActionMenu, item: Item, onSelect: OnSelect, current: Item | undefined }> = ({ menu, item, onSelect, current }) => { const isCurrent = current === item; return <div className='msp-control-row'> <button onClick={isCurrent ? () => menu.hide() : () => onSelect(item)}> {item.icon && <Icon name={item.icon} />} {isCurrent ? <b>{item.name}</b> : 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 }; } function createSpecFromSelectParamSimple(param: ParamDefinition.Select<any>) { const spec: Item[] = []; for (const [v, l] of param.options) { spec.push(ActionMenu.Item(l, v)); } return spec as Spec; } function createSpecFromSelectParamCategories(param: ParamDefinition.Select<any>) { const cats = new Map<string, (Item | string)[]>(); const spec: (Item | (Item | string)[] | string)[] = []; for (const [v, l, c] of param.options) { if (!!c) { let cat = cats.get(c); if (!cat) { cat = [c]; cats.set(c, cat); spec.push(cat); } cat.push(ActionMenu.Item(l, v)); } else { spec.push(ActionMenu.Item(l, v)); } } return spec as Spec; } export function createSpecFromSelectParam(param: ParamDefinition.Select<any>) { for (const o of param.options) { if (!!o[2]) return createSpecFromSelectParamCategories(param); } return createSpecFromSelectParamSimple(param); } export function findCurrent(spec: Spec, value: any): Item | undefined { if (typeof spec === 'string') return; if (isItem(spec)) return spec.value === value ? spec : void 0; for (const s of spec) { const found = findCurrent(s, value); if (found) return found; } } }