diff --git a/src/mol-plugin/context.ts b/src/mol-plugin/context.ts index 19dfe141da26b466c0619f7ab7d75b6a9c0fa382..1280f0262421a4ef00102cf5105ea06db1159d5f 100644 --- a/src/mol-plugin/context.ts +++ b/src/mol-plugin/context.ts @@ -21,6 +21,7 @@ import { PluginCommand, PluginCommands } from './command'; import { PluginSpec } from './spec'; import { PluginState } from './state'; import { TaskManager } from './util/task-manager'; +import { Color } from 'mol-util/color'; export class PluginContext { private disposed = false; @@ -68,6 +69,7 @@ export class PluginContext { initViewer(canvas: HTMLCanvasElement, container: HTMLDivElement) { try { (this.canvas3d as Canvas3D) = Canvas3D.create(canvas, container); + this.canvas3d.setProps({ backgroundColor: Color(0xFCFBF9) }); this.canvas3d.animate(); return true; } catch (e) { diff --git a/src/mol-plugin/skin/base/components/controls.scss b/src/mol-plugin/skin/base/components/controls.scss index 2ba55358c6a90255d29ba0cdff5196b9d3883451..7157b5f3306ec480ca8ea7b6686c4d09bddc7eef 100644 --- a/src/mol-plugin/skin/base/components/controls.scss +++ b/src/mol-plugin/skin/base/components/controls.scss @@ -1,4 +1,20 @@ +.msp-row { + position: relative; + height: $row-height; + background: $default-background; + margin-top: 1px; + + select, button, input[type=text] { + @extend .msp-form-control; + } + + button { + @extend .msp-btn; + @extend .msp-btn-block; + } +} + .msp-control-row { position: relative; height: $row-height; @@ -54,41 +70,41 @@ } .msp-slider { - > div { - > div:first-child { - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - width: 100%; - padding-right: 50px; - display: table; - - > div { - height: $row-height; - display: table-cell; - vertical-align: middle; - padding: 0 ($control-spacing + 4px); - } - } - > div:last-child { - position: absolute; + > div:first-child { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 50px; + width: 100%; + padding-right: 50px; + display: table; + + > div { height: $row-height; - right: 0; - width: 50px; - top: 0; - bottom: 0; + display: table-cell; + vertical-align: middle; + padding: 0 ($control-spacing + 4px); } } - - input[type=text] { - text-align: right; + > div:last-child { + position: absolute; + height: $row-height; + line-height: $row-height; + text-align: center; + right: 0; + width: 50px; + top: 0; + bottom: 0; } - input[type=range] { - width: 100%; - } + // input[type=text] { + // text-align: right; + // } + + // input[type=range] { + // width: 100%; + // } } .msp-toggle-color-picker { @@ -135,6 +151,42 @@ } } +.msp-control-offset { + // border-left-width: $control-spacing / 2; + // border-left-style: solid; + // border-left-color: color-increase-contrast($default-background, 10%); + // padding-left: 1px; + padding-left: $control-spacing; +} + +.msp-control-group-wrapper { + //border-left-width: $control-spacing / 2; + //border-left-style: solid; + //border-left-color: color-increase-contrast($default-background, 10%); + + margin-bottom: 1px; + padding-top: 1px; +} + +// TODO : get rid of the important +.msp-control-group-header { + > button { + padding-left: $control-spacing / 2 !important; + text-align: left !important; + height: 2 * $row-height / 3 !important; + line-height: 2 * $row-height / 3 !important; + font-size: 70% !important; + background: $default-background !important; + color: color-lower-contrast($font-color, 15%) !important; + } +} + +.msp-control-group-footer { + background: color-increase-contrast($default-background, 5%); + height: $control-spacing / 2; + font-size: 1px; + margin-top: 1px; +} .msp-control-subgroup { margin-top: 1px; diff --git a/src/mol-plugin/skin/base/layout/common.scss b/src/mol-plugin/skin/base/layout/common.scss index afd01479f0ae265c339f6677232dd0dabe89f564..22223522437dccecef88ab2da5f39ca81173cf90 100644 --- a/src/mol-plugin/skin/base/layout/common.scss +++ b/src/mol-plugin/skin/base/layout/common.scss @@ -16,7 +16,16 @@ position: absolute; } -.msp-layout-scrollable { +.msp-scrollable { + overflow-y: auto; +} + +.msp-scrollable-container { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; overflow-y: auto; } @@ -33,13 +42,12 @@ } } -.msp-layout-right { - +.msp-layout-right { .msp-layout-static { left: 0; right: 0; top: 0; - height: $row-height + $control-spacing; + bottom: 0; // height: $row-height + $control-spacing; } .msp-layout-scrollable { diff --git a/src/mol-plugin/skin/base/variables.scss b/src/mol-plugin/skin/base/variables.scss index ada1a971c269ba80592491b672c65f0d5e248161..6b440af0449f577c28c950d04456fa49fc7f74a5 100644 --- a/src/mol-plugin/skin/base/variables.scss +++ b/src/mol-plugin/skin/base/variables.scss @@ -11,8 +11,8 @@ $slider-border-radius-base: 6px; // layout $expanded-top-height: 100px; $expanded-bottom-height: 3 * $row-height + 2; -$expanded-right-width: 290px; -$expanded-left-width: 290px; +$expanded-right-width: 300px; +$expanded-left-width: 300px; $expanded-portrait-bottom-height: 10 * ($row-height + 1) + 3 * $control-spacing + 1; $expanded-portrait-top-height: 2 * $row-height + 1; diff --git a/src/mol-plugin/state/transforms/visuals.ts b/src/mol-plugin/state/transforms/visuals.ts index 49d63cf041ec2cdcbc9061801c53c34e30244929..cf1d4ee95bac8977817c3fe23c74cf714ac2f51f 100644 --- a/src/mol-plugin/state/transforms/visuals.ts +++ b/src/mol-plugin/state/transforms/visuals.ts @@ -19,18 +19,14 @@ namespace CreateStructureRepresentation { } const CreateStructureRepresentation = PluginStateTransform.Create<SO.Molecule.Structure, SO.Molecule.Representation3D, CreateStructureRepresentation.Params>({ name: 'create-structure-representation', - display: { name: 'Create 3D Representation' }, + display: { name: '3D Representation' }, from: [SO.Molecule.Structure], to: [SO.Molecule.Representation3D], params: (a, ctx: PluginContext) => ({ type: PD.Mapped( ctx.structureReprensentation.registry.default.name, ctx.structureReprensentation.registry.types, - name => PD.Group<any>( - ctx.structureReprensentation.registry.get(name).getParams(ctx.structureReprensentation.themeCtx, a.data), - { label: 'Type Parameters' } - ), - { label: 'Type' }) + name => PD.Group<any>(ctx.structureReprensentation.registry.get(name).getParams(ctx.structureReprensentation.themeCtx, a.data))) }), apply({ a, params }, plugin: PluginContext) { return Task.create('Structure Representation', async ctx => { diff --git a/src/mol-plugin/ui/controls/common.tsx b/src/mol-plugin/ui/controls/common.tsx new file mode 100644 index 0000000000000000000000000000000000000000..07e2c381325f0a39c936c3a174c068015183cd03 --- /dev/null +++ b/src/mol-plugin/ui/controls/common.tsx @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author David Sehnal <david.sehnal@gmail.com> + */ + +// export const ToggleButton = (props: { +// onChange: (v: boolean) => void, +// value: boolean, +// label: string, +// title?: string +// }) => <div className='lm-control-row lm-toggle-button' title={props.title}> +// <span>{props.label}</span> +// <div> +// <button onClick={e => { props.onChange.call(null, !props.value); (e.target as HTMLElement).blur(); }}> +// <span className={ `lm-icon lm-icon-${props.value ? 'ok' : 'off'}` }></span> {props.value ? 'On' : 'Off'} +// </button> +// </div> +// </div> \ 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 8203bc7990ed83f258e359f79a9f355cd4c9c491..2b96865733b527284eec7e68de510d164c961307 100644 --- a/src/mol-plugin/ui/controls/parameters.tsx +++ b/src/mol-plugin/ui/controls/parameters.tsx @@ -10,6 +10,7 @@ import { ParamDefinition as PD } from 'mol-util/param-definition'; import { camelCaseToWords } from 'mol-util/string'; import { ColorNames } from 'mol-util/color/tables'; import { Color } from 'mol-util/color'; +import { Slider } from './slider'; export interface ParameterControlsProps<P extends PD.Params = PD.Params> { params: P, @@ -38,7 +39,8 @@ function controlFor(param: PD.Any): ParamControl | undefined { switch (param.type) { case 'value': return void 0; case 'boolean': return BoolControl; - case 'number': return NumberControl; + case 'number': return typeof param.min !== 'undefined' && typeof param.max !== 'undefined' + ? NumberRangeControl : NumberInputControl; case 'converted': return ConvertedControl; case 'multi-select': return MultiSelectControl; case 'color': return ColorControl; @@ -67,33 +69,42 @@ export abstract class SimpleParam<P extends PD.Any> extends React.PureComponent< render() { const label = this.props.param.label || camelCaseToWords(this.props.name); - return <div style={{ padding: '0 3px', borderBottom: '1px solid #ccc' }}> - <div style={{ lineHeight: '20px', float: 'left' }} title={this.props.param.description}>{label}</div> - <div style={{ float: 'left', marginLeft: '5px' }}> + return <div className='msp-control-row'> + <span title={this.props.param.description}>{label}</span> + <div> {this.renderControl()} </div> - <div style={{ clear: 'both' }} /> </div>; } } export class BoolControl extends SimpleParam<PD.Boolean> { - onClick = () => { this.update(!this.props.value); } + onClick = (e: React.MouseEvent<HTMLButtonElement>) => { this.update(!this.props.value); e.currentTarget.blur(); } renderControl() { - return <button onClick={this.onClick} disabled={this.props.isDisabled}>{this.props.value ? 'âś“ On' : 'âś— Off'}</button>; + return <button onClick={this.onClick} disabled={this.props.isDisabled}> + <span className={`msp-icon msp-icon-${this.props.value ? 'ok' : 'off'}`} /> + {this.props.value ? 'On' : 'Off'} + </button>; } } -export class NumberControl extends SimpleParam<PD.Numeric> { +export class NumberInputControl extends SimpleParam<PD.Numeric> { onChange = (e: React.ChangeEvent<HTMLInputElement>) => { this.update(+e.target.value); } renderControl() { return <span> - <input type='range' value={'' + this.props.value} min={this.props.param.min} max={this.props.param.max} step={this.props.param.step} onChange={this.onChange} disabled={this.props.isDisabled} /> - <br />{this.props.value} + number input TODO </span> } } +export class NumberRangeControl extends SimpleParam<PD.Numeric> { + onChange = (v: number) => { this.update(v); } + renderControl() { + return <Slider value={this.props.value} min={this.props.param.min!} max={this.props.param.max!} + step={this.props.param.step} onChange={this.onChange} disabled={this.props.isDisabled} /> + } +} + export class TextControl extends SimpleParam<PD.Text> { onChange = (e: React.ChangeEvent<HTMLInputElement>) => { const value = e.target.value; @@ -153,34 +164,53 @@ export class ColorControl extends SimpleParam<PD.Color> { } } -export class MultiSelectControl extends React.PureComponent<ParamProps<PD.MultiSelect<any>>> { +export class MultiSelectControl extends React.PureComponent<ParamProps<PD.MultiSelect<any>>, { isExpanded: boolean }> { + state = { isExpanded: false } + change(value: PD.MultiSelect<any>['defaultValue'] ) { this.props.onChange({ name: this.props.name, param: this.props.param, value }); } toggle(key: string) { - return () => { + return (e: React.MouseEvent<HTMLButtonElement>) => { if (this.props.value.indexOf(key) < 0) this.change(this.props.value.concat(key)); - else this.change(this.props.value.filter(v => v !== key)) + else this.change(this.props.value.filter(v => v !== key)); + e.currentTarget.blur(); } } + toggleExpanded = (e: React.MouseEvent<HTMLButtonElement>) => { + this.setState({ isExpanded: !this.state.isExpanded }); + e.currentTarget.blur(); + } + render() { const current = this.props.value; const label = this.props.param.label || camelCaseToWords(this.props.name); - return <div> - <div>{label} <small>{`${current.length} of ${this.props.param.options.length}`}</small></div> - <div style={{ paddingLeft: '7px' }}> + return <> + <div className='msp-control-row'> + <span>{label}</span> + <div> + <button onClick={this.toggleExpanded}> + {`${current.length} of ${this.props.param.options.length}`} + </button> + </div> + </div> + <div className='msp-control-offset' style={{ display: this.state.isExpanded ? 'block' : 'none' }}> {this.props.param.options.map(([value, label]) => - <button key={value} onClick={this.toggle(value)} disabled={this.props.isDisabled}> - {current.indexOf(value) >= 0 ? `âś“ ${label}` : `âś— ${label}`} - </button>)} + <div key={value} className='msp-row'> + <button onClick={this.toggle(value)} disabled={this.props.isDisabled}> + {current.indexOf(value) >= 0 ? `âś“ ${label}` : `âś— ${label}`} + </button> + </div>)} </div> - </div>; + </>; } } -export class GroupControl extends React.PureComponent<ParamProps<PD.Group<any>>> { +export class GroupControl extends React.PureComponent<ParamProps<PD.Group<any>>, { isExpanded: boolean }> { + state = { isExpanded: false } + change(value: PD.Mapped<any>['defaultValue'] ) { this.props.onChange({ name: this.props.name, param: this.props.param, value }); } @@ -190,15 +220,25 @@ export class GroupControl extends React.PureComponent<ParamProps<PD.Group<any>>> this.change({ ...value.params, [e.name]: e.value }); } + toggleExpanded = () => this.setState({ isExpanded: !this.state.isExpanded }); + render() { const value: PD.Mapped<any>['defaultValue'] = this.props.value; const params = this.props.param.params; const label = this.props.param.label || camelCaseToWords(this.props.name); // TODO toggle panel - return <div> - <div>{label}</div> - <ParameterControls params={params} onChange={this.onChangeParam} values={value.params} onEnter={this.props.onEnter} isDisabled={this.props.isDisabled} /> + return <div className='msp-control-group-wrapper'> + <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'}`} /> + {label} + </button> + </div> + {this.state.isExpanded && <div className='msp-control-offset' style={{ display: this.state.isExpanded ? 'block' : 'none' }}> + <ParameterControls params={params} onChange={this.onChangeParam} values={value.params} onEnter={this.props.onEnter} isDisabled={this.props.isDisabled} /> + </div> + } </div> } } @@ -234,9 +274,7 @@ export class MappedControl extends React.PureComponent<ParamProps<PD.Mapped<any> return <div> {select} - <div style={{ borderLeft: '5px solid #777', paddingLeft: '5px' }}> - <Mapped param={param} value={value} name='' onChange={this.onChangeParam} onEnter={this.props.onEnter} isDisabled={this.props.isDisabled} /> - </div> + <Mapped param={param} value={value} name={`${label} Properties`} onChange={this.onChangeParam} onEnter={this.props.onEnter} isDisabled={this.props.isDisabled} /> </div> } } diff --git a/src/mol-plugin/ui/controls/slider.tsx b/src/mol-plugin/ui/controls/slider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..260903bbfa6ffd50c32716303c2ba18448b1cd90 --- /dev/null +++ b/src/mol-plugin/ui/controls/slider.tsx @@ -0,0 +1,809 @@ +/** + * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author David Sehnal <david.sehnal@gmail.com> + */ + +import * as React from 'react' + +export class Slider extends React.Component<{ + min: number, + max: number, + value: number, + step?: number, + onChange: (v: number) => void, + disabled?: boolean +}, { isChanging: boolean, current: number }> { + + state = { isChanging: false, current: 0 } + + static getDerivedStateFromProps(props: { value: number }, state: { isChanging: boolean, current: number }) { + if (state.isChanging || props.value === state.current) return null; + return { current: props.value }; + } + + begin = () => { + this.setState({ isChanging: true }); + } + + end = (v: number) => { + this.setState({ isChanging: false }); + this.props.onChange(v); + } + + updateCurrent = (current: number) => { + this.setState({ current }); + } + + render() { + let step = this.props.step; + if (step === void 0) step = 1; + return <div className='msp-slider'> + <div> + <div> + <SliderBase min={this.props.min} max={this.props.max} step={step} value={this.state.current} disabled={this.props.disabled} + onBeforeChange={this.begin} + onChange={this.updateCurrent as any} onAfterChange={this.end as any} /> + </div></div> + <div> + {`${Math.round(100 * this.state.current) / 100}`} + </div> + </div>; + } +} + +/** + * The following code was adapted from react-components/slider library. + * + * The MIT License (MIT) + * Copyright (c) 2015-present Alipay.com, https://www.alipay.com/ + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +function classNames(_classes: { [name: string]: boolean | number }) { + let classes = []; + let hasOwn = {}.hasOwnProperty; + + for (let i = 0; i < arguments.length; i++) { + let arg = arguments[i]; + if (!arg) continue; + + let argType = typeof arg; + + if (argType === 'string' || argType === 'number') { + classes.push(arg); + } else if (Array.isArray(arg)) { + classes.push(classNames.apply(null, arg)); + } else if (argType === 'object') { + for (let key in arg) { + if (hasOwn.call(arg, key) && arg[key]) { + classes.push(key); + } + } + } + } + + return classes.join(' '); +} + +function noop() { +} + +function isNotTouchEvent(e: TouchEvent) { + return e.touches.length > 1 || (e.type.toLowerCase() === 'touchend' && e.touches.length > 0); +} + +function getTouchPosition(vertical: boolean, e: TouchEvent) { + return vertical ? e.touches[0].clientY : e.touches[0].pageX; +} + +function getMousePosition(vertical: boolean, e: MouseEvent) { + return vertical ? e.clientY : e.pageX; +} + +function getHandleCenterPosition(vertical: boolean, handle: HTMLElement) { + const coords = handle.getBoundingClientRect(); + return vertical ? + coords.top + (coords.height * 0.5) : + coords.left + (coords.width * 0.5); +} + +function pauseEvent(e: Event) { + e.stopPropagation(); + e.preventDefault(); +} + +export class Handle extends React.Component<Partial<HandleProps>, {}> { + render() { + const { + className, + tipFormatter, + vertical, + offset, + value, + index, + } = this.props as HandleProps; + + const style = vertical ? { bottom: `${offset}%` } : { left: `${offset}%` }; + return ( + <div className={className} style={style} title={tipFormatter(value, index)} + /> + ); + } +} + +export interface SliderBaseProps { + min: number, + max: number, + step?: number, + defaultValue?: number | number[], + value?: number | number[], + marks?: any, + included?: boolean, + className?: string, + prefixCls?: string, + disabled?: boolean, + children?: any, + onBeforeChange?: (value: number | number[]) => void, + onChange?: (value: number | number[]) => void, + onAfterChange?: (value: number | number[]) => void, + handle?: JSX.Element, + tipFormatter?: (value: number, index: number) => any, + dots?: boolean, + range?: boolean | number, + vertical?: boolean, + allowCross?: boolean, + pushable?: boolean | number, +} + +export interface SliderBaseState { + handle: number | null, + recent: number, + bounds: number[] +} + +export class SliderBase extends React.Component<SliderBaseProps, SliderBaseState> { + private sliderElement: HTMLElement | undefined = void 0; + private handleElements: (HTMLElement | undefined)[] = []; + + constructor(props: SliderBaseProps) { + super(props); + + const { range, min, max } = props; + const initialValue = range ? Array.apply(null, Array(+range + 1)).map(() => min) : min; + const defaultValue = ('defaultValue' in props ? props.defaultValue : initialValue); + const value = (props.value !== undefined ? props.value : defaultValue); + + const bounds = (range ? value : [min, value]).map((v: number) => this.trimAlignValue(v)); + + let recent; + if (range && bounds[0] === bounds[bounds.length - 1] && bounds[0] === max) { + recent = 0; + } else { + recent = bounds.length - 1; + } + + this.state = { + handle: null, + recent, + bounds, + }; + } + + public static defaultProps: SliderBaseProps = { + prefixCls: 'msp-slider-base', + className: '', + min: 0, + max: 100, + step: 1, + marks: {}, + handle: <Handle className='' vertical={false} offset={0} tipFormatter={v => v} value={0} index={0} />, + onBeforeChange: noop, + onChange: noop, + onAfterChange: noop, + tipFormatter: (value, index) => value, + included: true, + disabled: false, + dots: false, + range: false, + vertical: false, + allowCross: true, + pushable: false, + }; + + private dragOffset = 0; + private startPosition = 0; + private startValue = 0; + private _getPointsCache: any = void 0; + + componentWillReceiveProps(nextProps: SliderBaseProps) { + if (!('value' in nextProps || 'min' in nextProps || 'max' in nextProps)) return; + + const { bounds } = this.state; + if (nextProps.range) { + const value = nextProps.value || bounds; + const nextBounds = (value as number[]).map((v: number) => this.trimAlignValue(v, nextProps)); + if (nextBounds.every((v: number, i: number) => v === bounds[i])) return; + + this.setState({ bounds: nextBounds } as SliderBaseState); + if (bounds.some(v => this.isValueOutOfBounds(v, nextProps))) { + this.props.onChange!(nextBounds); + } + } else { + const value = nextProps.value !== undefined ? nextProps.value : bounds[1]; + const nextValue = this.trimAlignValue(value as number, nextProps); + if (nextValue === bounds[1] && bounds[0] === nextProps.min) return; + + this.setState({ bounds: [nextProps.min, nextValue] } as SliderBaseState); + if (this.isValueOutOfBounds(bounds[1], nextProps)) { + this.props.onChange!(nextValue); + } + } + } + + onChange(state: this['state']) { + const props = this.props; + const isNotControlled = !('value' in props); + if (isNotControlled) { + this.setState(state); + } else if (state.handle !== undefined) { + this.setState({ handle: state.handle } as SliderBaseState); + } + + const data = { ...this.state, ...(state as any) }; + const changedValue = props.range ? data.bounds : data.bounds[1]; + props.onChange!(changedValue); + } + + onMouseDown(e: MouseEvent) { + if (e.button !== 0) { return; } + + let position = getMousePosition(this.props.vertical!, e); + if (!this.isEventFromHandle(e)) { + this.dragOffset = 0; + } else { + const handlePosition = getHandleCenterPosition(this.props.vertical!, e.target as HTMLElement); + this.dragOffset = position - handlePosition; + position = handlePosition; + } + this.onStart(position); + this.addDocumentEvents('mouse'); + pauseEvent(e); + } + + onMouseMove(e: MouseEvent) { + const position = getMousePosition(this.props.vertical!, e); + this.onMove(e, position - this.dragOffset); + } + + onMove(e: MouseEvent | TouchEvent, position: number) { + pauseEvent(e); + const props = this.props; + const state = this.state; + + let diffPosition = position - this.startPosition; + diffPosition = this.props.vertical ? -diffPosition : diffPosition; + const diffValue = diffPosition / this.getSliderLength() * (props.max - props.min); + + const value = this.trimAlignValue(this.startValue + diffValue); + const oldValue = state.bounds[state.handle!]; + if (value === oldValue) return; + + const nextBounds = [...state.bounds]; + nextBounds[state.handle!] = value; + let nextHandle = state.handle!; + if (props.pushable !== false) { + const originalValue = state.bounds[nextHandle]; + this.pushSurroundingHandles(nextBounds, nextHandle, originalValue); + } else if (props.allowCross) { + nextBounds.sort((a, b) => a - b); + nextHandle = nextBounds.indexOf(value); + } + this.onChange({ + handle: nextHandle, + bounds: nextBounds, + } as SliderBaseState); + } + + onStart(position: number) { + const props = this.props; + props.onBeforeChange!(this.getValue()); + + const value = this.calcValueByPos(position); + this.startValue = value; + this.startPosition = position; + + const state = this.state; + const { bounds } = state; + + let valueNeedChanging = 1; + if (this.props.range) { + let closestBound = 0; + for (let i = 1; i < bounds.length - 1; ++i) { + if (value > bounds[i]) { closestBound = i; } + } + if (Math.abs(bounds[closestBound + 1] - value) < Math.abs(bounds[closestBound] - value)) { + closestBound = closestBound + 1; + } + valueNeedChanging = closestBound; + + const isAtTheSamePoint = (bounds[closestBound + 1] === bounds[closestBound]); + if (isAtTheSamePoint) { + valueNeedChanging = state.recent; + } + + if (isAtTheSamePoint && (value !== bounds[closestBound + 1])) { + valueNeedChanging = value < bounds[closestBound + 1] ? closestBound : closestBound + 1; + } + } + + this.setState({ + handle: valueNeedChanging, + recent: valueNeedChanging, + } as SliderBaseState); + + const oldValue = state.bounds[valueNeedChanging]; + if (value === oldValue) return; + + const nextBounds = [...state.bounds]; + nextBounds[valueNeedChanging] = value; + this.onChange({ bounds: nextBounds } as SliderBaseState); + } + + onTouchMove(e: TouchEvent) { + if (isNotTouchEvent(e)) { + this.end('touch'); + return; + } + + const position = getTouchPosition(this.props.vertical!, e); + this.onMove(e, position - this.dragOffset); + } + + onTouchStart(e: TouchEvent) { + if (isNotTouchEvent(e)) return; + + let position = getTouchPosition(this.props.vertical!, e); + if (!this.isEventFromHandle(e)) { + this.dragOffset = 0; + } else { + const handlePosition = getHandleCenterPosition(this.props.vertical!, e.target as HTMLElement); + this.dragOffset = position - handlePosition; + position = handlePosition; + } + this.onStart(position); + this.addDocumentEvents('touch'); + pauseEvent(e); + } + + /** + * Returns an array of possible slider points, taking into account both + * `marks` and `step`. The result is cached. + */ + getPoints() { + const { marks, step, min, max } = this.props; + const cache = this._getPointsCache; + if (!cache || cache.marks !== marks || cache.step !== step) { + const pointsObject = { ...marks }; + if (step !== null) { + for (let point = min; point <= max; point += step!) { + pointsObject[point] = point; + } + } + const points = Object.keys(pointsObject).map(parseFloat); + points.sort((a, b) => a - b); + this._getPointsCache = { marks, step, points }; + } + return this._getPointsCache.points; + } + + getPrecision(step: number) { + const stepString = step.toString(); + let precision = 0; + if (stepString.indexOf('.') >= 0) { + precision = stepString.length - stepString.indexOf('.') - 1; + } + return precision; + } + + getSliderLength() { + const slider = this.sliderElement; + if (!slider) { + return 0; + } + + return this.props.vertical ? slider.clientHeight : slider.clientWidth; + } + + getSliderStart() { + const slider = this.sliderElement as HTMLElement; + const rect = slider.getBoundingClientRect(); + + return this.props.vertical ? rect.top : rect.left; + } + + getValue(): number { + const { bounds } = this.state; + return (this.props.range ? bounds : bounds[1]) as number; + } + + private eventHandlers = { + 'touchmove': (e: TouchEvent) => this.onTouchMove(e), + 'touchend': (e: TouchEvent) => this.end('touch'), + 'mousemove': (e: MouseEvent) => this.onMouseMove(e), + 'mouseup': (e: MouseEvent) => this.end('mouse'), + } + + addDocumentEvents(type: 'touch' | 'mouse') { + if (type === 'touch') { + document.addEventListener('touchmove', this.eventHandlers.touchmove); + document.addEventListener('touchend', this.eventHandlers.touchend); + } else if (type === 'mouse') { + document.addEventListener('mousemove', this.eventHandlers.mousemove); + document.addEventListener('mouseup', this.eventHandlers.mouseup); + } + } + + calcOffset(value: number) { + const { min, max } = this.props; + const ratio = (value - min) / (max - min); + return ratio * 100; + } + + calcValue(offset: number) { + const { vertical, min, max } = this.props; + const ratio = Math.abs(offset / this.getSliderLength()); + const value = vertical ? (1 - ratio) * (max - min) + min : ratio * (max - min) + min; + return value; + } + + calcValueByPos(position: number) { + const pixelOffset = position - this.getSliderStart(); + const nextValue = this.trimAlignValue(this.calcValue(pixelOffset)); + return nextValue; + } + + end(type: 'mouse' | 'touch') { + this.removeEvents(type); + this.props.onAfterChange!(this.getValue()); + this.setState({ handle: null } as SliderBaseState); + } + + isEventFromHandle(e: Event) { + for (const h of this.handleElements) { + if (h === e.target) return true; + } + return false; + + // return this.state.bounds.some((x, i) => e.target + + // ( + // //this.handleElements[i] && e.target === ReactDOM.findDOMNode(this.handleElements[i]) + // )); + } + + isValueOutOfBounds(value: number, props: SliderBaseProps) { + return value < props.min || value > props.max; + } + + pushHandle(bounds: number[], handle: number, direction: number, amount: number) { + const originalValue = bounds[handle]; + let currentValue = bounds[handle]; + while (direction * (currentValue - originalValue) < amount) { + if (!this.pushHandleOnePoint(bounds, handle, direction)) { + // can't push handle enough to create the needed `amount` gap, so we + // revert its position to the original value + bounds[handle] = originalValue; + return false; + } + currentValue = bounds[handle]; + } + // the handle was pushed enough to create the needed `amount` gap + return true; + } + + pushHandleOnePoint(bounds: number[], handle: number, direction: number) { + const points = this.getPoints(); + const pointIndex = points.indexOf(bounds[handle]); + const nextPointIndex = pointIndex + direction; + if (nextPointIndex >= points.length || nextPointIndex < 0) { + // reached the minimum or maximum available point, can't push anymore + return false; + } + const nextHandle = handle + direction; + const nextValue = points[nextPointIndex]; + const { pushable: threshold } = this.props; + const diffToNext = direction * (bounds[nextHandle] - nextValue); + if (!this.pushHandle(bounds, nextHandle, direction, +threshold! - diffToNext)) { + // couldn't push next handle, so we won't push this one either + return false; + } + // push the handle + bounds[handle] = nextValue; + return true; + } + + pushSurroundingHandles(bounds: number[], handle: number, originalValue: number) { + const { pushable: threshold } = this.props; + const value = bounds[handle]; + + let direction = 0; + if (bounds[handle + 1] - value < threshold!) { + direction = +1; + } else if (value - bounds[handle - 1] < threshold!) { + direction = -1; + } + + if (direction === 0) { return; } + + const nextHandle = handle + direction; + const diffToNext = direction * (bounds[nextHandle] - value); + if (!this.pushHandle(bounds, nextHandle, direction, +threshold! - diffToNext)) { + // revert to original value if pushing is impossible + bounds[handle] = originalValue; + } + } + + removeEvents(type: 'touch' | 'mouse') { + if (type === 'touch') { + document.removeEventListener('touchmove', this.eventHandlers.touchmove); + document.removeEventListener('touchend', this.eventHandlers.touchend); + } else if (type === 'mouse') { + document.removeEventListener('mousemove', this.eventHandlers.mousemove); + document.removeEventListener('mouseup', this.eventHandlers.mouseup); + } + } + + trimAlignValue(v: number, nextProps?: SliderBaseProps) { + const { handle, bounds } = (this.state || {}) as this['state']; + const { marks, step, min, max, allowCross } = { ...this.props, ...(nextProps || {}) } as SliderBaseProps; + + let val = v; + if (val <= min) { + val = min; + } + if (val >= max) { + val = max; + } + /* eslint-disable eqeqeq */ + if (!allowCross && handle != null && handle > 0 && val <= bounds[handle - 1]) { + val = bounds[handle - 1]; + } + if (!allowCross && handle != null && handle < bounds.length - 1 && val >= bounds[handle + 1]) { + val = bounds[handle + 1]; + } + /* eslint-enable eqeqeq */ + + const points = Object.keys(marks).map(parseFloat); + if (step !== null) { + const closestStep = (Math.round((val - min) / step!) * step!) + min; + points.push(closestStep); + } + + const diffs = points.map((point) => Math.abs(val - point)); + const closestPoint = points[diffs.indexOf(Math.min.apply(Math, diffs))]; + + return step !== null ? parseFloat(closestPoint.toFixed(this.getPrecision(step!))) : closestPoint; + } + + render() { + const { + handle, + bounds, + } = this.state; + const { + className, + prefixCls, + disabled, + vertical, + dots, + included, + range, + step, + marks, + max, min, + tipFormatter, + children, + } = this.props; + + const customHandle = this.props.handle; + + const offsets = bounds.map(v => this.calcOffset(v)); + + const handleClassName = `${prefixCls}-handle`; + + const handlesClassNames = bounds.map((v, i) => classNames({ + [handleClassName]: true, + [`${handleClassName}-${i + 1}`]: true, + [`${handleClassName}-lower`]: i === 0, + [`${handleClassName}-upper`]: i === bounds.length - 1, + })); + + const isNoTip = (step === null) || (tipFormatter === null); + + const commonHandleProps = { + prefixCls, + noTip: isNoTip, + tipFormatter, + vertical, + }; + + this.handleElements = []; + const handles = bounds.map((v, i) => React.cloneElement(customHandle!, { + ...commonHandleProps, + className: handlesClassNames[i], + value: v, + offset: offsets[i], + dragging: handle === i, + index: i, + key: i, + ref: (h: any) => this.handleElements.push(h) //`handle-${i}`, + })); + if (!range) { handles.shift(); } + + const isIncluded = included || range; + + const tracks: JSX.Element[] = []; + // for (let i = 1; i < bounds.length; ++i) { + // const trackClassName = classNames({ + // [`${prefixCls}-track`]: true, + // [`${prefixCls}-track-${i}`]: true, + // }); + // tracks.push( + // <Track className={trackClassName} vertical={vertical} included={isIncluded} + // offset={offsets[i - 1]} length={offsets[i] - offsets[i - 1]} key={i} + // /> + // ); + // } + + const sliderClassName = classNames({ + [prefixCls!]: true, + [`${prefixCls}-with-marks`]: Object.keys(marks).length, + [`${prefixCls}-disabled`]: disabled!, + [`${prefixCls}-vertical`]: this.props.vertical!, + [className!]: !!className, + }); + + return ( + <div ref={e => this.sliderElement = e!} className={sliderClassName} + onTouchStart={disabled ? noop : this.onTouchStart.bind(this)} + onMouseDown={disabled ? noop : this.onMouseDown.bind(this)} + > + <div className={`${prefixCls}-rail`} /> + {tracks} + <Steps prefixCls={prefixCls} vertical={vertical} marks={marks} dots={dots} step={step} + included={isIncluded} lowerBound={bounds[0]} + upperBound={bounds[bounds.length - 1]} max={max} min={min} + /> + {handles} + <Marks className={`${prefixCls}-mark`} vertical={vertical!} marks={marks} + included={isIncluded!} lowerBound={bounds[0]} + upperBound={bounds[bounds.length - 1]} max={max} min={min} + /> + {children} + </div> + ); + } +} + +export interface HandleProps { + className: string, + vertical: boolean, + offset: number, + tipFormatter: (v: number, index: number) => any, + value: number, + index: number, +} + +interface MarksProps { + className: string, + vertical: boolean, + marks: any, + included: boolean | number, + upperBound: number, + lowerBound: number, + max: number, + min: number +} +const Marks = ({ className, vertical, marks, included, upperBound, lowerBound, max, min }: MarksProps) => { + const marksKeys = Object.keys(marks); + const marksCount = marksKeys.length; + const unit = 100 / (marksCount - 1); + const markWidth = unit * 0.9; + + const range = max - min; + const elements = marksKeys.map(parseFloat).sort((a, b) => a - b).map((point) => { + const isActived = (!included && point === upperBound) || + (included && point <= upperBound && point >= lowerBound); + const markClassName = classNames({ + [`${className}-text`]: true, + [`${className}-text-active`]: isActived, + }); + + const bottomStyle = { + // height: markWidth + '%', + marginBottom: '-50%', + bottom: `${(point - min) / range * 100}%`, + }; + + const leftStyle = { + width: `${markWidth}%`, + marginLeft: `${-markWidth / 2}%`, + left: `${(point - min) / range * 100}%`, + }; + + const style = vertical ? bottomStyle : leftStyle; + + const markPoint = marks[point]; + const markPointIsObject = typeof markPoint === 'object' && !React.isValidElement(markPoint); + const markLabel = markPointIsObject ? markPoint.label : markPoint; + const markStyle = markPointIsObject ? { ...style, ...markPoint.style } : style; + return (<span className={markClassName} style={markStyle} key={point}> + {markLabel} + </span>); + }); + + return <div className={className}>{elements}</div>; +}; + +function calcPoints(vertical: boolean, marks: any, dots: boolean, step: number, min: number, max: number) { + const points = Object.keys(marks).map(parseFloat); + if (dots) { + for (let i = min; i <= max; i = i + step) { + if (points.indexOf(i) >= 0) continue; + points.push(i); + } + } + return points; +} + +const Steps = ({ prefixCls, vertical, marks, dots, step, included, + lowerBound, upperBound, max, min }: any) => { + const range = max - min; + const elements = calcPoints(vertical, marks, dots, step, min, max).map((point) => { + const offset = `${Math.abs(point - min) / range * 100}%`; + const style = vertical ? { bottom: offset } : { left: offset }; + + const isActived = (!included && point === upperBound) || + (included && point <= upperBound && point >= lowerBound); + const pointClassName = classNames({ + [`${prefixCls}-dot`]: true, + [`${prefixCls}-dot-active`]: isActived, + }); + + return <span className={pointClassName} style={style} key={point} />; + }); + + return <div className={`${prefixCls}-step`}>{elements}</div>; +}; + + // const Track = ({ className, included, vertical, offset, length }: any) => { + // const style: any = { + // visibility: included ? 'visible' : 'hidden' + // }; + // if (vertical) { + // style.bottom = `${offset}%`; + // style.height = `${length}%`; + // } else { + // style.left = `${offset}%`; + // style.width = `${length}%`; + // } + // return <div className={className} style={style} />; + // }; \ No newline at end of file diff --git a/src/mol-plugin/ui/plugin.tsx b/src/mol-plugin/ui/plugin.tsx index bb3ff2eeba2fe479de73488b56a8cebd9a1dfae2..b6087e76dcb2d899904901e6ec95e252c38bee34 100644 --- a/src/mol-plugin/ui/plugin.tsx +++ b/src/mol-plugin/ui/plugin.tsx @@ -21,39 +21,51 @@ import { PluginState } from 'mol-plugin/state'; import { UpdateTransformContol } from './state/update-transform'; export class Plugin extends React.Component<{ plugin: PluginContext }, {}> { + + region(kind: 'left' | 'right' | 'bottom' | 'main', element: JSX.Element) { + return <div className={`msp-layout-region msp-layout-${kind}`}> + <div className='msp-layout-static'> + {element} + </div> + </div> + } + render() { return <PluginReactContext.Provider value={this.props.plugin}> - <div style={{ position: 'absolute', width: '100%', height: '100%', fontFamily: 'monospace' }}> - <div style={{ position: 'absolute', width: '350px', height: '100%', overflowY: 'scroll', padding: '10px' }}> - <State /> - </div> - <div style={{ position: 'absolute', left: '350px', right: '300px', top: '0', bottom: '100px' }}> - <Viewport /> - <div style={{ position: 'absolute', left: '10px', top: '10px', height: '100%', color: 'white' }}> - <TrajectoryControls /> - </div> - <ViewportControls /> - <div style={{ position: 'absolute', left: '10px', bottom: '10px', color: 'white' }}> - <BackgroundTaskProgress /> + <div className='msp-plugin'> + <div className='msp-plugin-content msp-layout-expanded'> + <div className='msp-layout-hide-top'> + {this.region('main', <ViewportWrapper />)} + {this.region('left', <State />)} + {this.region('right', <div className='msp-scrollable-container'> + <CurrentObject /> + <Controls /> + <CameraSnapshots /> + <StateSnapshots /> + </div>)} + {this.region('bottom', <Log />)} </div> </div> - <div style={{ position: 'absolute', width: '300px', right: '0', top: '0', bottom: '0', padding: '10px', overflowY: 'scroll' }}> - <CurrentObject /> - <hr /> - <Controls /> - <hr /> - <CameraSnapshots /> - <hr /> - <StateSnapshots /> - </div> - <div style={{ position: 'absolute', right: '300px', left: '350px', bottom: '0', height: '100px', overflow: 'hidden' }}> - <Log /> - </div> </div> </PluginReactContext.Provider>; } } +export class ViewportWrapper extends PluginComponent { + render() { + return <> + <Viewport /> + <div style={{ position: 'absolute', left: '10px', top: '10px', height: '100%', color: 'white' }}> + <TrajectoryControls /> + </div> + <ViewportControls /> + <div style={{ position: 'absolute', left: '10px', bottom: '10px', color: 'white' }}> + <BackgroundTaskProgress /> + </div> + </>; + } +} + export class State extends PluginComponent { componentDidMount() { this.subscribe(this.plugin.state.behavior.kind, () => this.forceUpdate()); diff --git a/src/mol-plugin/ui/viewport.tsx b/src/mol-plugin/ui/viewport.tsx index 2ce8f7bb6319cdfa31364b12c0d33a976bad3f98..5424d514b7d005bb5adadad8ebfa89b679502eca 100644 --- a/src/mol-plugin/ui/viewport.tsx +++ b/src/mol-plugin/ui/viewport.tsx @@ -83,8 +83,8 @@ export class Viewport extends PluginComponent<{ }, ViewportState> { render() { if (this.state.noWebGl) return this.renderMissing(); - return <div style={{ backgroundColor: 'rgb(0, 0, 0)', width: '100%', height: '100%'}}> - <div ref={elm => this.container = elm} style={{width: '100%', height: '100%'}}> + return <div className='msp-viewport'> + <div className='msp-viewport-host3d' ref={elm => this.container = elm}> <canvas ref={elm => this.canvas = elm}></canvas> </div> </div>;