common.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. /**
  2. * Copyright (c) 2018-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 { Color } from '../../mol-util/color';
  8. import { Icon } from './icons';
  9. import ArrowRight from '@material-ui/icons/ArrowRight';
  10. import ArrowDropDown from '@material-ui/icons/ArrowDropDown';
  11. import Remove from '@material-ui/icons/Remove';
  12. import Add from '@material-ui/icons/Add';
  13. export type ColorAccent = 'cyan' | 'red' | 'gray' | 'green' | 'purple' | 'blue' | 'orange'
  14. export class ControlGroup extends React.Component<{
  15. header: string,
  16. title?: string,
  17. initialExpanded?: boolean,
  18. hideExpander?: boolean,
  19. hideOffset?: boolean,
  20. topRightIcon?: React.FC,
  21. headerLeftMargin?: string,
  22. onHeaderClick?: () => void,
  23. noTopMargin?: boolean,
  24. childrenClassName?: string,
  25. maxHeight?: string
  26. }, { isExpanded: boolean }> {
  27. state = { isExpanded: !!this.props.initialExpanded }
  28. headerClicked = () => {
  29. if (this.props.onHeaderClick) {
  30. this.props.onHeaderClick();
  31. } else {
  32. this.setState({ isExpanded: !this.state.isExpanded });
  33. }
  34. }
  35. render() {
  36. let groupClassName = this.props.hideOffset ? 'msp-control-group-children' : 'msp-control-group-children msp-control-offset';
  37. if (this.props.childrenClassName) groupClassName += ' ' + this.props.childrenClassName;
  38. // TODO: customize header style (bg color, togle button etc)
  39. return <div className='msp-control-group-wrapper' style={{ position: 'relative', marginTop: this.props.noTopMargin ? 0 : void 0 }}>
  40. <div className='msp-control-group-header' style={{ marginLeft: this.props.headerLeftMargin }} title={this.props.title}>
  41. <Button onClick={this.headerClicked}>
  42. {!this.props.hideExpander && <Icon svg={this.state.isExpanded ? ArrowRight : ArrowDropDown} />}
  43. {this.props.topRightIcon && <Icon svg={this.props.topRightIcon} style={{ position: 'absolute', right: '2px', top: 0 }} />}
  44. <b>{this.props.header}</b>
  45. </Button>
  46. </div>
  47. {this.state.isExpanded && <div className={groupClassName} style={{ display: this.state.isExpanded ? 'block' : 'none', maxHeight: this.props.maxHeight, overflow: 'hidden', overflowY: 'auto' }}>
  48. {this.props.children}
  49. </div>}
  50. </div>;
  51. }
  52. }
  53. export interface TextInputProps<T> {
  54. className?: string,
  55. style?: React.CSSProperties,
  56. value: T,
  57. fromValue?(v: T): string,
  58. toValue?(s: string): T,
  59. // TODO: add error/help messages here?
  60. isValid?(s: string): boolean,
  61. onChange(value: T): void,
  62. onEnter?(): void,
  63. onBlur?(): void,
  64. delayMs?: number,
  65. blurOnEnter?: boolean,
  66. blurOnEscape?: boolean,
  67. isDisabled?: boolean,
  68. placeholder?: string,
  69. numeric?: boolean
  70. }
  71. interface TextInputState {
  72. originalValue: string,
  73. value: string
  74. }
  75. function _id(x: any) { return x; }
  76. export class TextInput<T = string> extends React.PureComponent<TextInputProps<T>, TextInputState> {
  77. private input = React.createRef<HTMLInputElement>();
  78. private delayHandle: any = void 0;
  79. private pendingValue: T | undefined = void 0;
  80. state = { originalValue: '', value: '' }
  81. onBlur = () => {
  82. this.setState({ value: '' + this.state.originalValue });
  83. if (this.props.onBlur) this.props.onBlur();
  84. }
  85. get isPending() { return typeof this.delayHandle !== 'undefined'; }
  86. clearTimeout() {
  87. if (this.isPending) {
  88. clearTimeout(this.delayHandle);
  89. this.delayHandle = void 0;
  90. }
  91. }
  92. raiseOnChange = () => {
  93. if (this.pendingValue === void 0) return;
  94. this.props.onChange(this.pendingValue!);
  95. this.pendingValue = void 0;
  96. }
  97. triggerChanged(formatted: string, converted: T) {
  98. this.clearTimeout();
  99. if (formatted === this.state.originalValue) return;
  100. if (this.props.delayMs) {
  101. this.pendingValue = converted;
  102. this.delayHandle = setTimeout(this.raiseOnChange, this.props.delayMs);
  103. } else {
  104. this.props.onChange(converted);
  105. }
  106. }
  107. onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  108. const value = e.target.value;
  109. let isInvalid = (this.props.isValid && !this.props.isValid(value)) || (this.props.numeric && Number.isNaN(+value));
  110. if (isInvalid) {
  111. this.clearTimeout();
  112. this.setState({ value });
  113. return;
  114. }
  115. if (this.props.numeric) {
  116. this.setState({ value }, () => this.triggerChanged(value, +value as any));
  117. } else {
  118. const converted = (this.props.toValue || _id)(value);
  119. const formatted = (this.props.fromValue || _id)(converted);
  120. this.setState({ value: formatted }, () => this.triggerChanged(formatted, converted));
  121. }
  122. }
  123. onKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
  124. if (e.charCode === 27 || e.keyCode === 27 /* esc */) {
  125. if (this.props.blurOnEscape && this.input.current) {
  126. this.input.current.blur();
  127. }
  128. }
  129. }
  130. onKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
  131. if (e.keyCode === 13 || e.charCode === 13 /* enter */) {
  132. if (this.isPending) {
  133. this.clearTimeout();
  134. this.raiseOnChange();
  135. }
  136. if (this.props.blurOnEnter && this.input.current) {
  137. this.input.current.blur();
  138. }
  139. if (this.props.onEnter) this.props.onEnter();
  140. }
  141. e.stopPropagation();
  142. }
  143. static getDerivedStateFromProps(props: TextInputProps<any>, state: TextInputState) {
  144. const value = props.fromValue ? props.fromValue(props.value) : props.value;
  145. if (value === state.originalValue) return null;
  146. return { originalValue: value, value };
  147. }
  148. render() {
  149. return <input type='text'
  150. className={this.props.className}
  151. style={this.props.style}
  152. ref={this.input}
  153. onBlur={this.onBlur}
  154. value={this.state.value}
  155. placeholder={this.props.placeholder}
  156. onChange={this.onChange}
  157. onKeyPress={this.props.onEnter || this.props.blurOnEnter || this.props.blurOnEscape ? this.onKeyPress : void 0}
  158. onKeyDown={this.props.blurOnEscape ? this.onKeyUp : void 0}
  159. disabled={!!this.props.isDisabled}
  160. />;
  161. }
  162. }
  163. export class ExpandableControlRow extends React.Component<{
  164. label: string,
  165. colorStripe?: Color,
  166. pivot: JSX.Element,
  167. controls: JSX.Element
  168. }, { isExpanded: boolean }> {
  169. state = { isExpanded: false };
  170. toggleExpanded = () => this.setState({ isExpanded: !this.state.isExpanded });
  171. render() {
  172. const { label, pivot, controls } = this.props;
  173. // TODO: fix the inline CSS
  174. return <>
  175. <ControlRow label={<>
  176. {label}
  177. <button className='msp-btn-link msp-btn-icon msp-control-group-expander' onClick={this.toggleExpanded} title={`${this.state.isExpanded ? 'Less' : 'More'} options`}
  178. style={{ background: 'transparent', textAlign: 'left', padding: '0' }}>
  179. <Icon svg={this.state.isExpanded ? Remove : Add} style={{ display: 'inline-block' }} />
  180. </button>
  181. </>} control={pivot}>
  182. {this.props.colorStripe && <div className='msp-expandable-group-color-stripe' style={{ backgroundColor: Color.toStyle(this.props.colorStripe) }} />}
  183. </ControlRow>
  184. {this.state.isExpanded && <div className='msp-control-offset'>
  185. {controls}
  186. </div>}
  187. </>;
  188. }
  189. }
  190. export function SectionHeader(props: { icon?: React.FC, title: string | JSX.Element, desc?: string, accent?: ColorAccent }) {
  191. return <div className={`msp-section-header${props.accent ? ' msp-transform-header-brand-' + props.accent : ''}` }>
  192. {props.icon && <Icon svg={props.icon} />}
  193. {props.title} <small>{props.desc}</small>
  194. </div>;
  195. }
  196. export type ButtonProps = {
  197. style?: React.CSSProperties,
  198. className?: string,
  199. disabled?: boolean,
  200. title?: string,
  201. icon?: React.FC,
  202. commit?: boolean | 'on' | 'off'
  203. children?: React.ReactNode,
  204. onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void,
  205. onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void,
  206. onMouseEnter?: (e: React.MouseEvent<HTMLButtonElement>) => void,
  207. onMouseLeave?: (e: React.MouseEvent<HTMLButtonElement>) => void,
  208. inline?: boolean,
  209. 'data-id'?: string,
  210. 'data-color'?: Color,
  211. flex?: boolean | string | number,
  212. noOverflow?: boolean
  213. }
  214. export function Button(props: ButtonProps) {
  215. let className = 'msp-btn';
  216. if (!props.inline) className += ' msp-btn-block';
  217. if (props.noOverflow) className += ' msp-no-overflow';
  218. if (props.flex) className += ' msp-flex-item';
  219. if (props.commit === 'on' || props.commit) className += ' msp-btn-commit msp-btn-commit-on';
  220. if (props.commit === 'off') className += ' msp-btn-commit msp-btn-commit-off';
  221. if (!props.children) className += ' msp-btn-childless';
  222. if (props.className) className += ' ' + props.className;
  223. let style: React.CSSProperties | undefined = void 0;
  224. if (props.flex) {
  225. if (typeof props.flex === 'number') style = { flex: `0 0 ${props.flex}px`, padding: 0, maxWidth: `${props.flex}px` };
  226. else if (typeof props.flex === 'string') style = { flex: `0 0 ${props.flex}`, padding: 0, maxWidth: props.flex };
  227. }
  228. if (props.style) {
  229. if (style) Object.assign(style, props.style);
  230. else style = props.style;
  231. }
  232. return <button onClick={props.onClick} title={props.title} disabled={props.disabled} style={style} className={className} data-id={props['data-id']} data-color={props['data-color']}
  233. onContextMenu={props.onContextMenu} onMouseEnter={props.onMouseEnter} onMouseLeave={props.onMouseLeave}>
  234. {props.icon && <Icon svg={props.icon} />}
  235. {props.children}
  236. </button>;
  237. }
  238. export function IconButton(props: {
  239. svg?: React.FC,
  240. small?: boolean,
  241. onClick: (e: React.MouseEvent<HTMLButtonElement>) => void,
  242. title?: string,
  243. toggleState?: boolean,
  244. disabled?: boolean,
  245. className?: string,
  246. style?: React.CSSProperties,
  247. 'data-id'?: string,
  248. extraContent?: JSX.Element,
  249. flex?: boolean | string | number,
  250. transparent?: boolean
  251. }) {
  252. let className = `msp-btn msp-btn-icon${props.small ? '-small' : ''}${props.className ? ' ' + props.className : ''}`;
  253. if (typeof props.toggleState !== 'undefined') {
  254. className += ` msp-btn-link-toggle-${props.toggleState ? 'on' : 'off'}`;
  255. }
  256. if (props.transparent) {
  257. className += ' msp-transparent-bg';
  258. }
  259. let style: React.CSSProperties | undefined = void 0;
  260. if (props.flex) {
  261. if (typeof props.flex === 'boolean') style = { flex: '0 0 32px', padding: 0 };
  262. else if (typeof props.flex === 'number') style = { flex: `0 0 ${props.flex}px`, padding: 0, maxWidth: `${props.flex}px` };
  263. else style = { flex: `0 0 ${props.flex}`, padding: 0, maxWidth: props.flex };
  264. }
  265. if (props.style) {
  266. if (style) Object.assign(style, props.style);
  267. else style = props.style;
  268. }
  269. return <button className={className} onClick={props.onClick} title={props.title} disabled={props.disabled} data-id={props['data-id']} style={style}>
  270. {props.svg && <Icon svg={props.svg} />}
  271. {props.extraContent}
  272. </button>;
  273. }
  274. export type ToggleButtonProps = {
  275. style?: React.CSSProperties,
  276. className?: string,
  277. disabled?: boolean,
  278. label?: string | JSX.Element,
  279. title?: string,
  280. icon?: React.FC,
  281. isSelected?: boolean,
  282. toggle: () => void
  283. }
  284. export class ToggleButton extends React.PureComponent<ToggleButtonProps> {
  285. onClick = (e: React.MouseEvent<HTMLButtonElement>) => {
  286. e.currentTarget.blur();
  287. this.props.toggle();
  288. }
  289. render() {
  290. const props = this.props;
  291. const label = props.label;
  292. const className = props.isSelected ? `${props.className || ''} msp-control-current` : props.className;
  293. return <Button icon={this.props.icon} onClick={this.onClick} title={this.props.title}
  294. disabled={props.disabled} style={props.style} className={className}>
  295. {label && this.props.isSelected ? <b>{label}</b> : label}
  296. </Button>;
  297. }
  298. }
  299. export class ExpandGroup extends React.PureComponent<{ header: string, headerStyle?: React.CSSProperties, initiallyExpanded?: boolean, accent?: boolean, noOffset?: boolean, marginTop?: 0 | string, headerLeftMargin?: string }, { isExpanded: boolean }> {
  300. state = { isExpanded: !!this.props.initiallyExpanded };
  301. toggleExpanded = () => this.setState({ isExpanded: !this.state.isExpanded });
  302. render() {
  303. return <>
  304. <div className='msp-control-group-header' style={{ marginTop: this.props.marginTop !== void 0 ? this.props.marginTop : '1px', marginLeft: this.props.headerLeftMargin }}>
  305. <button className='msp-btn msp-form-control msp-btn-block' onClick={this.toggleExpanded} style={this.props.headerStyle}>
  306. <Icon svg={this.state.isExpanded ? ArrowDropDown : ArrowRight} />
  307. {this.props.header}
  308. </button>
  309. </div>
  310. {this.state.isExpanded &&
  311. (this.props.noOffset
  312. ? this.props.children
  313. : <div className={this.props.accent ? 'msp-accent-offset' : 'msp-control-offset'}>
  314. {this.props.children}
  315. </div>)}
  316. </>;
  317. }
  318. }
  319. export type ControlRowProps = {
  320. title?: string,
  321. label?: React.ReactNode,
  322. control?: React.ReactNode,
  323. className?: string,
  324. children?: React.ReactNode
  325. }
  326. export function ControlRow(props: ControlRowProps) {
  327. let className = 'msp-control-row';
  328. if (props.className) className += ' ' + props.className;
  329. return <div className={className}>
  330. <span className='msp-control-row-label' title={props.title}>{props.label}</span>
  331. <div className='msp-control-row-ctrl'>{props.control}</div>
  332. {props.children}
  333. </div>;
  334. }