diff --git a/src/mol-plugin-ui/image.tsx b/src/mol-plugin-ui/image.tsx deleted file mode 100644 index 854ce21f7b7d066d0260d56268979955d35d5473..0000000000000000000000000000000000000000 --- a/src/mol-plugin-ui/image.tsx +++ /dev/null @@ -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> - } -} \ No newline at end of file diff --git a/src/mol-plugin-ui/plugin.tsx b/src/mol-plugin-ui/plugin.tsx index 7a7eb0cd20ad8cd8a2c1bc7bb530ce5d79c3396e..6458d1cf78dc17ee6a16c32a5012ccf8f9584aae 100644 --- a/src/mol-plugin-ui/plugin.tsx +++ b/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>; } } diff --git a/src/mol-plugin-ui/skin/base/components/controls.scss b/src/mol-plugin-ui/skin/base/components/controls.scss index 87c3f3289ab692e082b2509b6810247804dae2fc..c24043385dcc30bc0b70e1639ea04cf1aefda437 100644 --- a/src/mol-plugin-ui/skin/base/components/controls.scss +++ b/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%; } } \ No newline at end of file diff --git a/src/mol-plugin-ui/skin/base/icons.scss b/src/mol-plugin-ui/skin/base/icons.scss index 28f271945142a228a1d99078dcb48777de99999a..f22ff1f8728052e1bc68636ce4e1288394a3f90c 100644 --- a/src/mol-plugin-ui/skin/base/icons.scss +++ b/src/mol-plugin-ui/skin/base/icons.scss @@ -235,4 +235,8 @@ .msp-icon-address:before { content: "\e841"; +} + +.msp-icon-download:before { + content: "\e82d"; } \ No newline at end of file diff --git a/src/mol-plugin-ui/viewport.tsx b/src/mol-plugin-ui/viewport.tsx index f7225df55230da079532ed40d7c904412fea6b56..a9d8f89c82a96eb307ee1dea904dc1e088cd6024 100644 --- a/src/mol-plugin-ui/viewport.tsx +++ b/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> } diff --git a/src/mol-plugin-ui/viewport/screenshot.tsx b/src/mol-plugin-ui/viewport/screenshot.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cb3d6e8a892e2ed87408398c14a09b9a4df7c6a3 --- /dev/null +++ b/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> + } +} \ No newline at end of file diff --git a/src/mol-plugin/util/viewport-screenshot.ts b/src/mol-plugin/util/viewport-screenshot.ts index 9ebef60d7908cd08fecf60df93fd42a34b999e87..0311ff3cb2673cb6fd2b87e912adfed21045f2ef 100644 --- a/src/mol-plugin/util/viewport-screenshot.ts +++ b/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()); }