Browse Source

mol-plugin: Toast support

David Sehnal 5 years ago
parent
commit
6a71af00cf

+ 8 - 0
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.
+        </>;
+    }
 }

+ 3 - 0
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());
 
             ////////////////////////////////////////////////////////
 

+ 18 - 0
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' });
         }
     }
 }

+ 5 - 0
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 }>(),

+ 3 - 1
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;

+ 4 - 5
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 {

+ 11 - 9
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;
 }
 

+ 110 - 0
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
+    }
+}

+ 4 - 2
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>)}

+ 6 - 2
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>
         </>;
     }
 }

+ 59 - 0
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>;
+    }
+}