action-menu.tsx 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. /**
  2. * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
  3. *
  4. * @author David Sehnal <david.sehnal@gmail.com>
  5. */
  6. import * as React from 'react'
  7. import { Icon } from './common';
  8. import { Subscription, BehaviorSubject, Observable } from 'rxjs';
  9. import { ParamDefinition } from '../../mol-util/param-definition';
  10. export class ActionMenu {
  11. private _command: BehaviorSubject<ActionMenu.Command>;
  12. get commands(): Observable<ActionMenu.Command> { return this._command; }
  13. hide() {
  14. this._command.next(HideCmd)
  15. }
  16. toggle(params: { items: ActionMenu.Spec, header?: string, current?: ActionMenu.Item, onSelect: (value: any) => void }) {
  17. this._command.next({ type: 'toggle', ...params });
  18. }
  19. constructor(defaultCommand?: ActionMenu.Command) {
  20. this._command = new BehaviorSubject<ActionMenu.Command>(defaultCommand || { type: 'hide' });
  21. }
  22. }
  23. const HideCmd: ActionMenu.Command = { type: 'hide' };
  24. export namespace ActionMenu {
  25. export type Command =
  26. | { type: 'toggle', items: Spec, header?: string, current?: Item, onSelect: (value: any) => void }
  27. | { type: 'hide' }
  28. function isToggleOff(a: Command, b: Command) {
  29. if (a.type === 'hide' || b.type === 'hide') return false;
  30. return a.onSelect === b.onSelect && a.items === b.items;
  31. }
  32. export type ToggleProps = {
  33. style?: React.HTMLAttributes<HTMLButtonElement>,
  34. className?: string,
  35. menu: ActionMenu,
  36. disabled?: boolean,
  37. items: ActionMenu.Spec,
  38. header?: string,
  39. label?: string,
  40. current?: ActionMenu.Item,
  41. onSelect: (value: any) => void
  42. }
  43. export class Toggle extends React.PureComponent<ToggleProps, { isSelected: boolean }> {
  44. private sub: Subscription | undefined = void 0;
  45. state = { isSelected: false };
  46. componentDidMount() {
  47. this.sub = this.props.menu.commands.subscribe(command => {
  48. if (command.type === 'hide') {
  49. this.hide();
  50. } else if (command.type === 'toggle') {
  51. const cmd = this.props;
  52. if (command.items === cmd.items && command.onSelect === cmd.onSelect) {
  53. this.setState({ isSelected: !this.state.isSelected });
  54. } else {
  55. this.hide();
  56. }
  57. }
  58. });
  59. }
  60. componentWillUnmount() {
  61. if (!this.sub) return;
  62. this.sub.unsubscribe();
  63. this.sub = void 0;
  64. }
  65. hide = () => this.setState({ isSelected: false });
  66. onClick = (e: React.MouseEvent<HTMLButtonElement>) => {
  67. e.currentTarget.blur();
  68. this.props.menu.toggle(this.props);
  69. }
  70. render() {
  71. const props = this.props;
  72. const label = props.label || props.header;
  73. return <button onClick={this.onClick}
  74. disabled={props.disabled} style={props.style} className={props.className}>
  75. {this.state.isSelected ? <b>{label}</b> : label}
  76. </button>;
  77. }
  78. }
  79. export class Options extends React.PureComponent<{ menu: ActionMenu }, { command: Command, isVisible: boolean }> {
  80. private sub: Subscription | undefined = void 0;
  81. state = { isVisible: false, command: HideCmd };
  82. componentDidMount() {
  83. this.sub = this.props.menu.commands.subscribe(command => {
  84. if (command.type === 'hide' || isToggleOff(command, this.state.command)) {
  85. this.setState({ isVisible: false, command: HideCmd });
  86. } else {
  87. this.setState({ isVisible: true, command })
  88. }
  89. });
  90. }
  91. componentWillUnmount() {
  92. if (!this.sub) return;
  93. this.sub.unsubscribe();
  94. this.sub = void 0;
  95. }
  96. onSelect: OnSelect = item => {
  97. const cmd = this.state.command;
  98. this.hide();
  99. if (cmd.type === 'toggle') cmd.onSelect(item.value);
  100. }
  101. hide = () => {
  102. this.props.menu.hide();
  103. }
  104. render() {
  105. if (!this.state.isVisible || this.state.command.type !== 'toggle') return null;
  106. return <div className='msp-action-menu-options' style={{ marginTop: '1px' }}>
  107. {this.state.command.header && <div className='msp-control-group-header' style={{ position: 'relative' }}>
  108. <button className='msp-btn msp-btn-block' onClick={this.hide}>
  109. <Icon name='off' style={{ position: 'absolute', right: '2px', top: 0 }} />
  110. <b>{this.state.command.header}</b>
  111. </button>
  112. </div>}
  113. <Section menu={this.props.menu} items={this.state.command.items} onSelect={this.onSelect} current={this.state.command.current} />
  114. </div>
  115. }
  116. }
  117. type SectionProps = { menu: ActionMenu, header?: string, items: Spec, onSelect: OnSelect, current: Item | undefined }
  118. type SectionState = { items: Spec, current: Item | undefined, isExpanded: boolean }
  119. class Section extends React.PureComponent<SectionProps, SectionState> {
  120. state = {
  121. items: this.props.items,
  122. current: this.props.current,
  123. isExpanded: !!this.props.current && !!findCurrent(this.props.items, this.props.current.value)
  124. }
  125. toggleExpanded = (e: React.MouseEvent<HTMLButtonElement>) => {
  126. this.setState({ isExpanded: !this.state.isExpanded });
  127. e.currentTarget.blur();
  128. }
  129. static getDerivedStateFromProps(props: SectionProps, state: SectionState) {
  130. if (props.items === state.items && props.current === state.current) return null;
  131. return { items: props.items, current: props.current, isExpanded: props.current && !!findCurrent(props.items, props.current.value) }
  132. }
  133. render() {
  134. const { header, items, onSelect, current, menu } = this.props;
  135. if (typeof items === 'string') return null;
  136. if (isItem(items)) return <Action menu={menu} item={items} onSelect={onSelect} current={current} />
  137. return <div>
  138. {header && <div className='msp-control-group-header' style={{ marginTop: '1px' }}>
  139. <button className='msp-btn msp-btn-block' onClick={this.toggleExpanded}>
  140. <span className={`msp-icon msp-icon-${this.state.isExpanded ? 'collapse' : 'expand'}`} />
  141. {header}
  142. </button>
  143. </div>}
  144. <div className='msp-control-offset'>
  145. {(!header || this.state.isExpanded) && items.map((x, i) => {
  146. if (typeof x === 'string') return null;
  147. if (isItem(x)) return <Action menu={menu} key={i} item={x} onSelect={onSelect} current={current} />
  148. return <Section menu={menu} key={i} header={typeof x[0] === 'string' ? x[0] : void 0} items={x} onSelect={onSelect} current={current} />
  149. })}
  150. </div>
  151. </div>;
  152. }
  153. }
  154. const Action: React.FC<{ menu: ActionMenu, item: Item, onSelect: OnSelect, current: Item | undefined }> = ({ menu, item, onSelect, current }) => {
  155. const isCurrent = current === item;
  156. return <div className='msp-control-row'>
  157. <button onClick={isCurrent ? () => menu.hide() : () => onSelect(item)}>
  158. {item.icon && <Icon name={item.icon} />}
  159. {isCurrent ? <b>{item.name}</b> : item.name}
  160. </button>
  161. </div>;
  162. }
  163. type OnSelect = (item: Item) => void
  164. function isItem(x: any): x is Item {
  165. const v = x as Item;
  166. return v && !!v.name && typeof v.value !== 'undefined';
  167. }
  168. export type OptionsParams = { items: Spec, header?: string, onSelect: (value: any) => void }
  169. export type Spec = string | Item | [Spec]
  170. export type Item = { name: string, icon?: string, value: unknown }
  171. export function Item(name: string, value: unknown): Item
  172. export function Item(name: string, icon: string, value: unknown): Item
  173. export function Item(name: string, iconOrValue: any, value?: unknown): Item {
  174. if (value) return { name, icon: iconOrValue, value };
  175. return { name, value: iconOrValue };
  176. }
  177. function createSpecFromSelectParamSimple(param: ParamDefinition.Select<any>) {
  178. const spec: Item[] = [];
  179. for (const [v, l] of param.options) {
  180. spec.push(ActionMenu.Item(l, v));
  181. }
  182. return spec as Spec;
  183. }
  184. function createSpecFromSelectParamCategories(param: ParamDefinition.Select<any>) {
  185. const cats = new Map<string, (Item | string)[]>();
  186. const spec: (Item | (Item | string)[] | string)[] = [];
  187. for (const [v, l, c] of param.options) {
  188. if (!!c) {
  189. let cat = cats.get(c);
  190. if (!cat) {
  191. cat = [c];
  192. cats.set(c, cat);
  193. spec.push(cat);
  194. }
  195. cat.push(ActionMenu.Item(l, v));
  196. } else {
  197. spec.push(ActionMenu.Item(l, v));
  198. }
  199. }
  200. return spec as Spec;
  201. }
  202. export function createSpecFromSelectParam(param: ParamDefinition.Select<any>) {
  203. for (const o of param.options) {
  204. if (!!o[2]) return createSpecFromSelectParamCategories(param);
  205. }
  206. return createSpecFromSelectParamSimple(param);
  207. }
  208. export function findCurrent(spec: Spec, value: any): Item | undefined {
  209. if (typeof spec === 'string') return;
  210. if (isItem(spec)) return spec.value === value ? spec : void 0;
  211. for (const s of spec) {
  212. const found = findCurrent(s, value);
  213. if (found) return found;
  214. }
  215. }
  216. }