diff --git a/src/apps/basic-wrapper/controls.tsx b/src/apps/basic-wrapper/controls.tsx index d7b678f29e13f5b2644978d344d27ff3c2e4026a..6fdcfdd1a38cdab8b089626316fdc6a90800fca2 100644 --- a/src/apps/basic-wrapper/controls.tsx +++ b/src/apps/basic-wrapper/controls.tsx @@ -19,4 +19,12 @@ export class BasicWrapperControls extends PluginUIComponent { <TransformUpdaterControl nodeRef='ihm-visual' header={{ name: 'I/HM Visual' }} initiallyCollapsed={true} /> </div>; } +} + +export class CustomToastMessage extends PluginUIComponent { + render() { + return <> + Custom <i>Toast</i> content. No timeout. + </>; + } } \ No newline at end of file diff --git a/src/apps/basic-wrapper/index.html b/src/apps/basic-wrapper/index.html index cb24e98ba931d901fc1b957c99c0328ba08a7433..a1ce87e3cc816aed7a8572d8bc2f6419f761535c 100644 --- a/src/apps/basic-wrapper/index.html +++ b/src/apps/basic-wrapper/index.html @@ -110,6 +110,9 @@ addControl('Static Superposition', () => BasicMolStarWrapper.tests.staticSuperposition()); addControl('Dynamic Superposition', () => BasicMolStarWrapper.tests.dynamicSuperposition()); addControl('Validation Tooltip', () => BasicMolStarWrapper.tests.toggleValidationTooltip()); + + addControl('Show Toasts', () => BasicMolStarWrapper.tests.showToasts()); + addControl('Hide Toasts', () => BasicMolStarWrapper.tests.hideToasts()); //////////////////////////////////////////////////////// diff --git a/src/apps/basic-wrapper/index.ts b/src/apps/basic-wrapper/index.ts index c722e95e43d234e4edc8150f7088ac811d3a709e..aa2a450770e0c41f9218816df4dec458285803e4 100644 --- a/src/apps/basic-wrapper/index.ts +++ b/src/apps/basic-wrapper/index.ts @@ -18,6 +18,7 @@ import { StripedResidues } from './coloring'; // import { BasicWrapperControls } from './controls'; import { StaticSuperpositionTestData, buildStaticSuperposition, dynamicSuperpositionTest } from './superposition'; import { PDBeStructureQualityReport } from '../../mol-plugin/behavior/dynamic/custom-props'; +import { CustomToastMessage } from './controls'; require('mol-plugin/skin/light.scss') type SupportedFormats = 'cif' | 'pdb' @@ -158,6 +159,23 @@ class BasicWrapper { const state = this.plugin.state.behaviorState; const tree = state.build().to(PDBeStructureQualityReport.id).update(PDBeStructureQualityReport, p => ({ ...p, showTooltip: !p.showTooltip })); await PluginCommands.State.Update.dispatch(this.plugin, { state, tree }); + }, + showToasts: () => { + PluginCommands.Toast.Show.dispatch(this.plugin, { + title: 'Toast 1', + message: 'This is an example text, timeout 3s', + key: 'toast-1', + timeoutMs: 3000 + }); + PluginCommands.Toast.Show.dispatch(this.plugin, { + title: 'Toast 2', + message: CustomToastMessage, + key: 'toast-2' + }); + }, + hideToasts: () => { + PluginCommands.Toast.Hide.dispatch(this.plugin, { key: 'toast-1' }); + PluginCommands.Toast.Hide.dispatch(this.plugin, { key: 'toast-2' }); } } } diff --git a/src/mol-plugin/command.ts b/src/mol-plugin/command.ts index f29c1a5d7e9d152c96f4d54387203fa870837fae..a861e3ba3f4b70527e3c06f5a7bda3268467bf2b 100644 --- a/src/mol-plugin/command.ts +++ b/src/mol-plugin/command.ts @@ -12,6 +12,7 @@ import { PluginLayoutStateProps } from './layout'; import { StructureElement } from '../mol-model/structure'; import { PluginState } from './state'; import { Interactivity } from './util/interactivity'; +import { PluginToast } from './state/toast'; export * from './command/base'; @@ -53,6 +54,10 @@ export const PluginCommands = { Layout: { Update: PluginCommand<{ state: Partial<PluginLayoutStateProps> }>() }, + Toast: { + Show: PluginCommand<PluginToast>(), + Hide: PluginCommand<{ key: string }>() + }, Camera: { Reset: PluginCommand<{}>(), SetSnapshot: PluginCommand<{ snapshot: Partial<Camera.Snapshot>, durationMs?: number }>(), diff --git a/src/mol-plugin/context.ts b/src/mol-plugin/context.ts index 078f17989af87b595cb239031f3af681af3742bd..f79d6ec5903857132bd4675e35e232a4338f8ae5 100644 --- a/src/mol-plugin/context.ts +++ b/src/mol-plugin/context.ts @@ -40,6 +40,7 @@ import { Interactivity } from './util/interactivity'; import { StructureRepresentationHelper } from './util/structure-representation-helper'; import { StructureSelectionHelper } from './util/structure-selection-helper'; import { StructureOverpaintHelper } from './util/structure-overpaint-helper'; +import { PluginToastManager } from './state/toast'; interface Log { entries: List<LogEntry> @@ -100,7 +101,8 @@ export class PluginContext { } as const readonly canvas3d: Canvas3D; - readonly layout: PluginLayout = new PluginLayout(this); + readonly layout = new PluginLayout(this); + readonly toasts = new PluginToastManager(this); readonly interactivity: Interactivity; readonly lociLabels: LociLabelManager; diff --git a/src/mol-plugin/skin/base/components/toast.scss b/src/mol-plugin/skin/base/components/toast.scss index ff9c109007f2101aa89ffb267ba26f55b8d5d988..70f2ab52a1dcc8b2c524ef4f297fe20d2f6c5596 100644 --- a/src/mol-plugin/skin/base/components/toast.scss +++ b/src/mol-plugin/skin/base/components/toast.scss @@ -1,10 +1,9 @@ .msp-toast-container { - position: absolute; - max-width: 100%; - bottom: $control-spacing; - right: $control-spacing; - margin-left: $control-spacing; + position: relative; + // bottom: $control-spacing; + // right: $control-spacing; + // margin-left: $control-spacing; z-index: 1001; .msp-toast-entry { diff --git a/src/mol-plugin/skin/base/components/viewport.scss b/src/mol-plugin/skin/base/components/viewport.scss index a6dd6f3e8cf3731d50c92bd97eb641f0d9d37816..cd0715cd76d32c90144441d672082c184d869eea 100644 --- a/src/mol-plugin/skin/base/components/viewport.scss +++ b/src/mol-plugin/skin/base/components/viewport.scss @@ -75,23 +75,25 @@ } } -/* highlight */ +/* highlight & toasts */ -.msp-highlight-info { +.msp-highlight-toast-wrapper { + position: absolute; + right: $control-spacing; + bottom: $control-spacing; + max-width: 95%; + + z-index: 10000; +} +.msp-highlight-info { color: $highlight-info-font-color; padding: $info-vertical-padding $control-spacing; background: $default-background; //$highlight-info-background; - position: absolute; - right: $control-spacing; - bottom: $control-spacing; + min-height: $row-height; text-align: right; - min-height: $row-height; - max-width: 95%; - //border-bottom-right-radius: 6px; - z-index: 10000; @include non-selectable; } diff --git a/src/mol-plugin/state/toast.ts b/src/mol-plugin/state/toast.ts new file mode 100644 index 0000000000000000000000000000000000000000..c810a2686cdad7bbee2ce9d9a8a7a0cf60b7aa25 --- /dev/null +++ b/src/mol-plugin/state/toast.ts @@ -0,0 +1,110 @@ +/** + * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * Adapted from LiteMol (c) David Sehnal + * + * @author David Sehnal <david.sehnal@gmail.com> + */ + +import { PluginComponent } from '../component'; +import { OrderedMap } from 'immutable'; +import { PluginContext } from '../context'; +import { PluginCommands } from '../command'; + +export interface PluginToast { + title: string, + /** + * The message can be either a string, html string, or an arbitrary React component. + */ + message: string | React.ComponentClass, + /** + * Only one message with a given key can be shown. + */ + key?: string, + /** + * Specify a timeout for the message in milliseconds. + */ + timeoutMs?: number +} + +export class PluginToastManager extends PluginComponent<{ + entries: OrderedMap<number, PluginToastManager.Entry> +}> { + readonly events = { + changed: this.ev() + }; + + private serialNumber = 0; + private serialId = 0; + + private findByKey(key: string): PluginToastManager.Entry | undefined { + return this.state.entries.find(e => !!e && e.key === key) + } + + private show(toast: PluginToast) { + let entries = this.state.entries; + let e: PluginToastManager.Entry | undefined = void 0; + const id = ++this.serialId; + let serialNumber: number; + if (toast.key && (e = this.findByKey(toast.key))) { + if (e.timeout !== void 0) clearTimeout(e.timeout); + serialNumber = e.serialNumber; + entries = entries.remove(e.id); + } else { + serialNumber = ++this.serialNumber; + } + + e = { + id, + serialNumber, + key: toast.key, + title: toast.title, + message: toast.message, + timeout: this.timeout(id, toast.timeoutMs), + hide: () => this.hideId(id) + }; + + if (this.updateState({ entries: entries.set(id, e) })) this.events.changed.next(); + } + + private timeout(id: number, delay?: number) { + if (delay === void 0) return void 0; + + if (delay < 0) delay = 500; + return <number><any>setTimeout(() => { + const e = this.state.entries.get(id); + e.timeout = void 0; + this.hide(e); + }, delay); + } + + private hideId(id: number) { + this.hide(this.state.entries.get(id)); + } + + private hide(e: PluginToastManager.Entry | undefined) { + if (!e) return; + if (e.timeout !== void 0) clearTimeout(e.timeout); + e.hide = <any>void 0; + if (this.updateState({ entries: this.state.entries.delete(e.id) })) this.events.changed.next(); + } + + constructor(plugin: PluginContext) { + super({ entries: OrderedMap<number, PluginToastManager.Entry>() }); + + PluginCommands.Toast.Show.subscribe(plugin, e => this.show(e)); + PluginCommands.Toast.Hide.subscribe(plugin, e => this.hide(this.findByKey(e.key))); + } +} + +export namespace PluginToastManager { + export interface Entry { + id: number, + serialNumber: number, + key?: string, + title: string, + message: string | React.ComponentClass, + hide: () => void, + timeout?: number + } +} \ No newline at end of file diff --git a/src/mol-plugin/ui/controls.tsx b/src/mol-plugin/ui/controls.tsx index 416dd79dcd1a2ca285d3b485151ea7cd30099f1e..df288666e14a3a010746afbbd56a9d01e1663a6a 100644 --- a/src/mol-plugin/ui/controls.tsx +++ b/src/mol-plugin/ui/controls.tsx @@ -238,7 +238,7 @@ export class AnimationViewportControls extends PluginUIComponent<{}, { isEmpty: } } -export class LociLabelControl extends PluginUIComponent<{}, { entries: ReadonlyArray<LociLabelEntry> }> { +export class LociLabels extends PluginUIComponent<{}, { entries: ReadonlyArray<LociLabelEntry> }> { state = { entries: [] } componentDidMount() { @@ -246,7 +246,9 @@ export class LociLabelControl extends PluginUIComponent<{}, { entries: ReadonlyA } render() { - if (this.state.entries.length === 0) return null; + if (this.state.entries.length === 0) { + return null; + } return <div className='msp-highlight-info'> {this.state.entries.map((e, i) => <div key={'' + i}>{e}</div>)} diff --git a/src/mol-plugin/ui/plugin.tsx b/src/mol-plugin/ui/plugin.tsx index fc8cc6f2818bc93d828c676cccf20260eafbf772..23e3c3ad373b2d5f8aee5f160d516491f3f0e629 100644 --- a/src/mol-plugin/ui/plugin.tsx +++ b/src/mol-plugin/ui/plugin.tsx @@ -12,7 +12,7 @@ import { LogEntry } from '../../mol-util/log-entry'; import * as React from 'react'; import { PluginContext } from '../context'; import { PluginReactContext, PluginUIComponent } from './base'; -import { LociLabelControl, TrajectoryViewportControls, StateSnapshotViewportControls, AnimationViewportControls, StructureToolsWrapper } from './controls'; +import { LociLabels, TrajectoryViewportControls, StateSnapshotViewportControls, AnimationViewportControls, StructureToolsWrapper } from './controls'; import { StateSnapshots } from './state'; import { StateObjectActions } from './state/actions'; import { StateTree } from './state/tree'; @@ -21,6 +21,7 @@ import { Viewport, ViewportControls } from './viewport'; import { StateTransform } from '../../mol-state'; import { UpdateTransformControl } from './state/update-transform'; import { SequenceView } from './sequence'; +import { Toasts } from './toast'; export class Plugin extends React.Component<{ plugin: PluginContext }, {}> { region(kind: 'left' | 'right' | 'bottom' | 'main', element: JSX.Element) { @@ -146,7 +147,10 @@ export class ViewportWrapper extends PluginUIComponent { <div style={{ position: 'absolute', left: '10px', bottom: '10px' }}> <BackgroundTaskProgress /> </div> - <LociLabelControl /> + <div className='msp-highlight-toast-wrapper'> + <LociLabels /> + <Toasts /> + </div> </>; } } diff --git a/src/mol-plugin/ui/toast.tsx b/src/mol-plugin/ui/toast.tsx new file mode 100644 index 0000000000000000000000000000000000000000..446ee16de4ead3113c50418693c60fb33f19c30e --- /dev/null +++ b/src/mol-plugin/ui/toast.tsx @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * Adapted from LiteMol (c) David Sehnal + * + * @author David Sehnal <david.sehnal@gmail.com> + */ + +import * as React from 'react'; +import { PluginUIComponent } from './base'; +import { PluginToastManager } from '../state/toast'; +import { IconButton } from './controls/common'; + +class ToastEntry extends PluginUIComponent<{ entry: PluginToastManager.Entry }> { + private hide = () => { + let entry = this.props.entry; + (entry.hide || function () { }).call(null); + }; + + render() { + let entry = this.props.entry; + let message = typeof entry.message === 'string' + ? <div dangerouslySetInnerHTML={{ __html: entry.message }} /> + : <div><entry.message /></div>; + + return <div className='msp-toast-entry'> + <div className='msp-toast-title' onClick={() => this.hide()}> + {entry.title} + </div> + <div className='msp-toast-message'> + {message} + </div> + <div className='msp-toast-clear'></div> + <div className='msp-toast-hide'> + <IconButton onClick={this.hide} icon='abort' title='Hide' /> + </div> + </div>; + } +} + +export class Toasts extends PluginUIComponent { + componentDidMount() { + this.subscribe(this.plugin.toasts.events.changed, () => this.forceUpdate()); + } + + render() { + const state = this.plugin.toasts.state; + + if (!state.entries.count()) return null; + + const entries: PluginToastManager.Entry[] = []; + state.entries.forEach((t, k) => entries.push(t!)); + entries.sort(function (x, y) { return x.serialNumber - y.serialNumber; }) + + return <div className='msp-toast-container'> + {entries.map(e => <ToastEntry key={e.serialNumber} entry={e} />)} + </div>; + } +}