Просмотр исходного кода

mol-plugin: screenshot controls

David Sehnal 5 лет назад
Родитель
Сommit
b75ba4b4aa

+ 0 - 190
src/mol-plugin-ui/image.tsx

@@ -1,190 +0,0 @@
-/**
- * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
- *
- * @author Alexander Rose <alexander.rose@weirdbyte.de>
- */
-
-import * as React from 'react';
-import { CollapsableControls, CollapsableState } from './base';
-import { ParamDefinition as PD } from '../mol-util/param-definition';
-import { ParameterControls } from './controls/parameters';
-import { setCanvasSize } from '../mol-canvas3d/util';
-
-interface ImageControlsState extends CollapsableState {
-    showPreview: boolean
-
-    size: 'canvas' | 'custom'
-    width: number
-    height: number
-
-    isDisabled: boolean
-}
-
-const maxWidthUi = 260
-const maxHeightUi = 180
-
-export class ImageControls<P, S extends ImageControlsState> extends CollapsableControls<P, S> {
-    private canvasRef = React.createRef<HTMLCanvasElement>()
-
-    private canvas: HTMLCanvasElement
-    private canvasContext: CanvasRenderingContext2D
-
-    // private imagePass: ImagePass
-
-    get imagePass() {
-        return this.plugin.helpers.viewportScreenshot!.imagePass;
-    }
-
-    constructor(props: P, context?: any) {
-        super(props, context)
-
-        this.subscribe(this.plugin.events.canvas3d.initialized, () => this.forceUpdate())
-    }
-
-    private getSize() {
-        return this.state.size === 'canvas' && this.plugin.canvas3d ? {
-            width: this.plugin.canvas3d.webgl.gl.drawingBufferWidth,
-            height: this.plugin.canvas3d.webgl.gl.drawingBufferHeight
-        } : {
-            width: this.state.width,
-            height: this.state.height
-        }
-    }
-
-    private preview = () => {
-        const { width, height } = this.getSize()
-        if (width <= 0 || height <= 0) return
-
-        let w: number, h: number
-        const aH = maxHeightUi / height
-        const aW = maxWidthUi / width
-        if (aH < aW) {
-            h = Math.round(Math.min(maxHeightUi, height))
-            w = Math.round(width * (h / height))
-        } else {
-            w = Math.round(Math.min(maxWidthUi, width))
-            h = Math.round(height * (w / width))
-        }
-        setCanvasSize(this.canvas, w, h)
-        const pixelRatio = this.plugin.canvas3d?.webgl.pixelRatio || 1
-        const pw = Math.round(w * pixelRatio)
-        const ph = Math.round(h * pixelRatio)
-        const imageData = this.imagePass.getImageData(pw, ph)
-        this.canvasContext.putImageData(imageData, 0, 0)
-    }
-
-    private download = () => {
-        this.plugin.helpers.viewportScreenshot?.download();
-    }
-
-    private syncCanvas() {
-        if (!this.canvasRef.current) return
-        if (this.canvasRef.current === this.canvas) return
-
-        this.canvas = this.canvasRef.current
-        const ctx = this.canvas.getContext('2d')
-        if (!ctx) throw new Error('Could not get canvas 2d context')
-        this.canvasContext = ctx
-    }
-
-    private handlePreview() {
-        if (this.state.showPreview) {
-            this.syncCanvas()
-            this.preview()
-        }
-    }
-
-    componentDidUpdate() {
-        this.plugin.helpers.viewportScreenshot!.size = this.getSize();
-        this.handlePreview()
-    }
-
-    componentDidMount() {
-        if (!this.plugin.canvas3d) return;
-
-        this.handlePreview()
-
-        this.subscribe(this.plugin.events.canvas3d.settingsUpdated, () => {
-            this.imagePass.setProps({
-                multiSample: { mode: 'on', sampleLevel: 2 },
-                postprocessing: this.plugin.canvas3d?.props.postprocessing
-            })
-            this.handlePreview()
-        })
-
-        this.subscribe(this.plugin.canvas3d.didDraw, () => {
-            this.handlePreview()
-        })
-
-        this.subscribe(this.plugin.state.dataState.events.isUpdating, v => this.setState({ isDisabled: v }))
-    }
-
-    private togglePreview = () => this.setState({ showPreview: !this.state.showPreview })
-
-    private setProps = (p: { param: PD.Base<any>, name: string, value: any }) => {
-        if (p.name === 'size') {
-            if (p.value.name === 'custom') {
-                this.setState({ size: p.value.name, width: p.value.params.width, height: p.value.params.height })
-            } else {
-                this.setState({ size: p.value.name })
-            }
-        }
-    }
-
-    private get params () {
-        const max = Math.min(this.plugin.canvas3d ? this.plugin.canvas3d.webgl.maxRenderbufferSize : 4096, 8192)
-        const { width, height } = this.defaultState()
-        return {
-            size: PD.MappedStatic('custom', {
-                canvas: PD.Group({}),
-                custom: PD.Group({
-                    width: PD.Numeric(width, { min: 128, max, step: 1 }),
-                    height: PD.Numeric(height, { min: 128, max, step: 1 }),
-                }, { isFlat: true })
-            }, { options: [['canvas', 'Canvas'], ['custom', 'Custom']] })
-        }
-    }
-
-    private get values () {
-        return this.state.size === 'canvas'
-            ? { size: { name: 'canvas', params: {} } }
-            : { size: { name: 'custom', params: { width: this.state.width, height: this.state.height } } }
-    }
-
-    protected defaultState() {
-        return {
-            isCollapsed: false,
-            header: 'Create Image',
-
-            showPreview: false,
-
-            size: 'canvas',
-            width: 1920,
-            height: 1080,
-
-            isDisabled: false
-        } as S
-    }
-
-    protected renderControls() {
-        return <div>
-            <div className='msp-control-row'>
-                <button className='msp-btn msp-btn-block' onClick={this.download} disabled={this.state.isDisabled}>Download</button>
-            </div>
-            <ParameterControls params={this.params} values={this.values} onChange={this.setProps} isDisabled={this.state.isDisabled} />
-            <div className='msp-control-group-wrapper'>
-                <div className='msp-control-group-header'>
-                    <button className='msp-btn msp-btn-block' onClick={this.togglePreview}>
-                        <span className={`msp-icon msp-icon-${this.state.showPreview ? 'collapse' : 'expand'}`} />
-                        Preview
-                    </button>
-                </div>
-                {this.state.showPreview && <div className='msp-control-offset'>
-                    <div className='msp-image-preview'>
-                        <canvas width='0px' height='0px' ref={this.canvasRef} />
-                    </div>
-                </div>}
-            </div>
-        </div>
-    }
-}

+ 0 - 2
src/mol-plugin-ui/plugin.tsx

@@ -19,7 +19,6 @@ import { StateTransform } from '../mol-state';
 import { UpdateTransformControl } from './state/update-transform';
 import { SequenceView } from './sequence';
 import { Toasts } from './toast';
-import { ImageControls } from './image';
 import { SectionHeader } from './controls/common';
 import { LeftPanelControls } from './left-panel';
 
@@ -128,7 +127,6 @@ export class ControlsWrapper extends PluginUIComponent {
             {/* <AnimationControlsWrapper /> */}
             {/* <CameraSnapshots /> */}
             <StructureToolsWrapper />
-            <ImageControls />
         </div>;
     }
 }

+ 14 - 14
src/mol-plugin-ui/skin/base/components/controls.scss

@@ -376,19 +376,19 @@
     position: relative;
     background: $default-background;
     margin-top: 1px;
-    display: flex;
-    justify-content: center;
-
-    > canvas {
-        max-height: 200px;
-        border-width: 0px 1px 0px 1px;
-        border-style: solid;
-        border-color: $border-color;
-
-        background-color: $default-background;
-        background-image: linear-gradient(45deg, lightgrey 25%, transparent 25%, transparent 75%, lightgrey 75%, lightgrey),
-        linear-gradient(45deg, lightgrey 25%, transparent 25%, transparent 75%, lightgrey 75%, lightgrey);
-        background-size: 20px 20px;
-        background-position: 0 0, 10px 10px;
+    text-align: center;
+    padding: $control-spacing;
+
+    img {
+        max-height: 180px;
+        max-width: 100%;
+        display: 'block';
+    }
+
+    > span {
+        margin-top: 6px;
+        display: block;
+        text-align: center;
+        font-size: 80%;
     }
 }

+ 4 - 0
src/mol-plugin-ui/skin/base/icons.scss

@@ -235,4 +235,8 @@
 
 .msp-icon-address:before {
 	content: "\e841";
+}
+
+.msp-icon-download:before {
+	content: "\e82d";
 }

+ 20 - 18
src/mol-plugin-ui/viewport.tsx

@@ -12,9 +12,11 @@ import { ParamDefinition as PD } from '../mol-util/param-definition';
 import { PluginUIComponent } from './base';
 import { ControlGroup, IconButton } from './controls/common';
 import { SimpleSettingsControl } from './viewport/simple-settings';
+import { DownloadScreenshotControls } from './viewport/screenshot';
 
 interface ViewportControlsState {
     isSettingsExpanded: boolean,
+    isScreenshotExpanded: boolean,
     isHelpExpanded: boolean
 }
 
@@ -22,24 +24,28 @@ interface ViewportControlsProps {
 }
 
 export class ViewportControls extends PluginUIComponent<ViewportControlsProps, ViewportControlsState> {
-    state = {
+    private allCollapsedState: ViewportControlsState = {
         isSettingsExpanded: false,
+        isScreenshotExpanded: false,
         isHelpExpanded: false
     };
 
+    state = { ...this.allCollapsedState } as ViewportControlsState;
+
     resetCamera = () => {
         PluginCommands.Camera.Reset.dispatch(this.plugin, {});
     }
 
-    toggleSettingsExpanded = (e?: React.MouseEvent<HTMLButtonElement>) => {
-        this.setState({ isSettingsExpanded: !this.state.isSettingsExpanded, isHelpExpanded: false });
-        e?.currentTarget.blur();
+    private toggle(panel: keyof ViewportControlsState) {
+        return (e?: React.MouseEvent<HTMLButtonElement>) => {
+            this.setState({ ...this.allCollapsedState, [panel]: !this.state[panel] });
+            e?.currentTarget.blur();
+        };
     }
 
-    toggleHelpExpanded = (e?: React.MouseEvent<HTMLButtonElement>) => {
-        this.setState({ isSettingsExpanded: false, isHelpExpanded: !this.state.isHelpExpanded });
-        e?.currentTarget.blur();
-    }
+    toggleSettingsExpanded = this.toggle('isSettingsExpanded');
+    toggleHelpExpanded = this.toggle('isHelpExpanded');
+    toggleScreenshotExpanded = this.toggle('isScreenshotExpanded');
 
     toggleControls = () => {
         PluginCommands.Layout.Update.dispatch(this.plugin, { state: { showControls: !this.plugin.layout.state.showControls } });
@@ -89,7 +95,7 @@ export class ViewportControls extends PluginUIComponent<ViewportControlsProps, V
                 </div>
                 <div>
                     <div className='msp-semi-transparent-background' />
-                    {this.icon('screenshot', this.screenshot, 'Download Screenshot')}
+                    {this.icon('screenshot', this.toggleScreenshotExpanded, 'Screenshot', this.state.isScreenshotExpanded)}
                 </div>
                 <div>
                     <div className='msp-semi-transparent-background' />
@@ -107,19 +113,15 @@ export class ViewportControls extends PluginUIComponent<ViewportControlsProps, V
                     <HelpContent />
                 </ControlGroup>
             </div>} */}
+            {this.state.isScreenshotExpanded && <div className='msp-viewport-controls-panel'>
+                <ControlGroup header='Screenshot' initialExpanded={true} hideExpander={true} hideOffset={true} onHeaderClick={this.toggleScreenshotExpanded} topRightIcon='off'>
+                    <DownloadScreenshotControls close={this.toggleScreenshotExpanded} />
+                </ControlGroup>
+            </div>}
             {this.state.isSettingsExpanded && <div className='msp-viewport-controls-panel'>
                 <ControlGroup header='Basic Settings' initialExpanded={true} hideExpander={true} hideOffset={true} onHeaderClick={this.toggleSettingsExpanded} topRightIcon='off'>
                     <SimpleSettingsControl />
                 </ControlGroup>
-                {/* <ControlGroup header='Layout' initialExpanded={true}>
-                    <ParameterControls params={PluginLayoutStateParams} values={this.plugin.layout.state} onChange={this.setLayout} />
-                </ControlGroup>
-                <ControlGroup header='Interactivity' initialExpanded={true}>
-                    <ParameterControls params={Interactivity.Params} values={this.plugin.interactivity.props} onChange={this.setInteractivityProps} />
-                </ControlGroup>
-                {this.plugin.canvas3d && <ControlGroup header='Viewport' initialExpanded={true}>
-                    <ParameterControls params={Canvas3DParams} values={this.plugin.canvas3d.props} onChange={this.setSettings} />
-                </ControlGroup>} */}
             </div>}
         </div>
     }

+ 112 - 0
src/mol-plugin-ui/viewport/screenshot.tsx

@@ -0,0 +1,112 @@
+/**
+ * Copyright (c) 2019 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import * as React from 'react';
+import { ParamDefinition as PD } from '../../mol-util/param-definition';
+import { ParameterControls } from '../controls/parameters';
+import { PluginUIComponent } from '../base';
+import { Icon } from '../controls/common';
+import { debounceTime } from 'rxjs/operators';
+import { Subject } from 'rxjs';
+
+interface ImageControlsState {
+    showPreview: boolean
+
+    size: 'canvas' | 'custom'
+    width: number
+    height: number
+
+    isDisabled: boolean
+}
+
+export class DownloadScreenshotControls extends PluginUIComponent<{ close: () => void }, ImageControlsState> {
+    state: ImageControlsState = {
+        showPreview: true,
+        ...this.plugin.helpers.viewportScreenshot?.size,
+        isDisabled: false
+    } as ImageControlsState
+
+    private imgRef = React.createRef<HTMLImageElement>()
+    private updateQueue = new Subject();
+
+    get imagePass() {
+        return this.plugin.helpers.viewportScreenshot!.imagePass;
+    }
+
+    private preview = async () => {
+        if (!this.imgRef.current) return;
+        this.imgRef.current!.src = await this.plugin.helpers.viewportScreenshot!.imageData();
+    }
+
+    private download = () => {
+        this.plugin.helpers.viewportScreenshot?.download();
+        this.props.close();
+    }
+
+    private handlePreview() {
+        if (this.state.showPreview) {
+            this.preview()
+        }
+    }
+
+    componentDidUpdate() {
+        this.updateQueue.next();
+    }
+
+    componentDidMount() {
+        if (!this.plugin.canvas3d) return;
+
+        this.subscribe(debounceTime(250)(this.updateQueue), () => this.handlePreview());
+
+        this.subscribe(this.plugin.events.canvas3d.settingsUpdated, () => {
+            this.imagePass.setProps({
+                multiSample: { mode: 'on', sampleLevel: 2 },
+                postprocessing: this.plugin.canvas3d?.props.postprocessing
+            })
+            this.updateQueue.next();
+        })
+
+        this.subscribe(debounceTime(250)(this.plugin.canvas3d.didDraw), () => {
+            if (this.state.isDisabled) return;
+            this.updateQueue.next();
+        })
+
+        this.subscribe(this.plugin.state.dataState.events.isUpdating, v => {
+            this.setState({ isDisabled: v })
+            if (!v) this.updateQueue.next();
+        })
+
+        this.handlePreview();
+    }
+
+    private setProps = (p: { param: PD.Base<any>, name: string, value: any }) => {
+        if (p.name === 'size') {
+            if (p.value.name === 'custom') {
+                this.plugin.helpers.viewportScreenshot!.size.type = 'custom';
+                this.plugin.helpers.viewportScreenshot!.size.width = p.value.params.width;
+                this.plugin.helpers.viewportScreenshot!.size.height = p.value.params.height;
+                this.setState({ size: p.value.name, width: p.value.params.width, height: p.value.params.height })
+            } else {
+                this.plugin.helpers.viewportScreenshot!.size.type = 'canvas';
+                this.setState({ size: p.value.name })
+            }
+        }
+    }
+
+    render() {
+        return <div>
+            <div className='msp-image-preview'>
+                <img ref={this.imgRef} /><br />
+                <span>Right-click the image to Copy.</span>
+            </div>
+            <div className='msp-control-row'>
+                <button className='msp-btn msp-btn-block' onClick={this.download} disabled={this.state.isDisabled}><Icon name='download' /> Download</button>
+            </div>
+            <ParameterControls params={this.plugin.helpers.viewportScreenshot!.params} values={this.plugin.helpers.viewportScreenshot!.values} onChange={this.setProps} isDisabled={this.state.isDisabled} />
+        </div>
+    }
+}

+ 61 - 16
src/mol-plugin/util/viewport-screenshot.ts

@@ -9,11 +9,33 @@ import { PluginContext } from '../context';
 import { ImagePass } from '../../mol-canvas3d/passes/image';
 import { StateSelection } from '../../mol-state';
 import { PluginStateObject } from '../state/objects';
-import { Task } from '../../mol-task';
+import { Task, RuntimeContext } from '../../mol-task';
 import { canvasToBlob } from '../../mol-canvas3d/util';
 import { download } from '../../mol-util/download';
+import { ParamDefinition as PD } from '../../mol-util/param-definition';
+import { SyncRuntimeContext } from '../../mol-task/execution/synchronous';
 
 export class ViewportScreenshotWrapper {
+    get params() {
+        const max = Math.min(this.plugin.canvas3d ? this.plugin.canvas3d.webgl.maxRenderbufferSize : 4096, 4096)
+        const { width, height } = this.size
+        return {
+            size: PD.MappedStatic(this.size.type, {
+                canvas: PD.Group({}),
+                custom: PD.Group({
+                    width: PD.Numeric(width, { min: 128, max, step: 1 }),
+                    height: PD.Numeric(height, { min: 128, max, step: 1 }),
+                }, { isFlat: true })
+            }, { options: [['canvas', 'Canvas'], ['custom', 'Custom']] })
+        }
+    }
+
+    get values() {
+        return this.size.type === 'canvas'
+            ? { size: { name: 'canvas', params: {} } }
+            : { size: { name: 'custom', params: { width: this.size.width, height: this.size.height } } }
+    }
+
     private getCanvasSize() {
         return {
             width: this.plugin.canvas3d?.webgl.gl.drawingBufferWidth || 0,
@@ -21,7 +43,16 @@ export class ViewportScreenshotWrapper {
         };
     }
 
-    size = this.getCanvasSize();
+    size = {
+        type: 'custom' as 'canvas' | 'custom',
+        width: 1920,
+        height: 1080
+    }
+
+    getSize() {
+        if (this.size.type === 'canvas') return this.getCanvasSize();
+        return { width: this.size.width, height: this.size.height };
+    }
 
     private _imagePass: ImagePass;
 
@@ -44,28 +75,42 @@ export class ViewportScreenshotWrapper {
         return `${idString || 'molstar-image'}.png`
     }
 
-    private downloadTask() {
-        return Task.create('Download Image', async ctx => {
-            const { width, height } = this.size
-            if (width <= 0 || height <= 0) return
+    private canvas = function () {
+        const canvas = document.createElement('canvas');
+        return canvas;
+    }();
 
-            await ctx.update('Rendering image...')
-            const imageData = this.imagePass.getImageData(width, height)
+    private async draw(ctx: RuntimeContext) {
+        const { width, height } = this.getSize();
+        if (width <= 0 || height <= 0) return;
 
-            await ctx.update('Encoding image...')
-            const canvas = document.createElement('canvas')
-            canvas.width = imageData.width
-            canvas.height = imageData.height
-            const canvasCtx = canvas.getContext('2d')
-            if (!canvasCtx) throw new Error('Could not create canvas 2d context')
-            canvasCtx.putImageData(imageData, 0, 0)
+        await ctx.update('Rendering image...')
+        const imageData = this.imagePass.getImageData(width, height);
+
+        await ctx.update('Encoding image...')
+        const canvas = this.canvas
+        canvas.width = imageData.width
+        canvas.height = imageData.height
+        const canvasCtx = canvas.getContext('2d')
+        if (!canvasCtx) throw new Error('Could not create canvas 2d context')
+        canvasCtx.putImageData(imageData, 0, 0)
+        return;
+    }
 
+    private downloadTask() {
+        return Task.create('Download Image', async ctx => {
+            this.draw(ctx);
             await ctx.update('Downloading image...')
-            const blob = await canvasToBlob(canvas, 'png')
+            const blob = await canvasToBlob(this.canvas, 'png')
             download(blob, this.getFilename())
         })
     }
 
+    async imageData() {
+        await this.draw(SyncRuntimeContext)
+        return this.canvas.toDataURL();
+    }
+
     download() {
         this.plugin.runTask(this.downloadTask());
     }