Skip to content
Snippets Groups Projects
Commit b75ba4b4 authored by David Sehnal's avatar David Sehnal
Browse files

mol-plugin: screenshot controls

parent baa80d08
No related branches found
No related tags found
No related merge requests found
/**
* 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
...@@ -19,7 +19,6 @@ import { StateTransform } from '../mol-state'; ...@@ -19,7 +19,6 @@ import { StateTransform } from '../mol-state';
import { UpdateTransformControl } from './state/update-transform'; import { UpdateTransformControl } from './state/update-transform';
import { SequenceView } from './sequence'; import { SequenceView } from './sequence';
import { Toasts } from './toast'; import { Toasts } from './toast';
import { ImageControls } from './image';
import { SectionHeader } from './controls/common'; import { SectionHeader } from './controls/common';
import { LeftPanelControls } from './left-panel'; import { LeftPanelControls } from './left-panel';
...@@ -128,7 +127,6 @@ export class ControlsWrapper extends PluginUIComponent { ...@@ -128,7 +127,6 @@ export class ControlsWrapper extends PluginUIComponent {
{/* <AnimationControlsWrapper /> */} {/* <AnimationControlsWrapper /> */}
{/* <CameraSnapshots /> */} {/* <CameraSnapshots /> */}
<StructureToolsWrapper /> <StructureToolsWrapper />
<ImageControls />
</div>; </div>;
} }
} }
......
...@@ -376,19 +376,19 @@ ...@@ -376,19 +376,19 @@
position: relative; position: relative;
background: $default-background; background: $default-background;
margin-top: 1px; margin-top: 1px;
display: flex; text-align: center;
justify-content: center; padding: $control-spacing;
> canvas { img {
max-height: 200px; max-height: 180px;
border-width: 0px 1px 0px 1px; max-width: 100%;
border-style: solid; display: 'block';
border-color: $border-color; }
background-color: $default-background; > span {
background-image: linear-gradient(45deg, lightgrey 25%, transparent 25%, transparent 75%, lightgrey 75%, lightgrey), margin-top: 6px;
linear-gradient(45deg, lightgrey 25%, transparent 25%, transparent 75%, lightgrey 75%, lightgrey); display: block;
background-size: 20px 20px; text-align: center;
background-position: 0 0, 10px 10px; font-size: 80%;
} }
} }
\ No newline at end of file
...@@ -236,3 +236,7 @@ ...@@ -236,3 +236,7 @@
.msp-icon-address:before { .msp-icon-address:before {
content: "\e841"; content: "\e841";
} }
.msp-icon-download:before {
content: "\e82d";
}
\ No newline at end of file
...@@ -12,9 +12,11 @@ import { ParamDefinition as PD } from '../mol-util/param-definition'; ...@@ -12,9 +12,11 @@ import { ParamDefinition as PD } from '../mol-util/param-definition';
import { PluginUIComponent } from './base'; import { PluginUIComponent } from './base';
import { ControlGroup, IconButton } from './controls/common'; import { ControlGroup, IconButton } from './controls/common';
import { SimpleSettingsControl } from './viewport/simple-settings'; import { SimpleSettingsControl } from './viewport/simple-settings';
import { DownloadScreenshotControls } from './viewport/screenshot';
interface ViewportControlsState { interface ViewportControlsState {
isSettingsExpanded: boolean, isSettingsExpanded: boolean,
isScreenshotExpanded: boolean,
isHelpExpanded: boolean isHelpExpanded: boolean
} }
...@@ -22,24 +24,28 @@ interface ViewportControlsProps { ...@@ -22,24 +24,28 @@ interface ViewportControlsProps {
} }
export class ViewportControls extends PluginUIComponent<ViewportControlsProps, ViewportControlsState> { export class ViewportControls extends PluginUIComponent<ViewportControlsProps, ViewportControlsState> {
state = { private allCollapsedState: ViewportControlsState = {
isSettingsExpanded: false, isSettingsExpanded: false,
isScreenshotExpanded: false,
isHelpExpanded: false isHelpExpanded: false
}; };
state = { ...this.allCollapsedState } as ViewportControlsState;
resetCamera = () => { resetCamera = () => {
PluginCommands.Camera.Reset.dispatch(this.plugin, {}); PluginCommands.Camera.Reset.dispatch(this.plugin, {});
} }
toggleSettingsExpanded = (e?: React.MouseEvent<HTMLButtonElement>) => { private toggle(panel: keyof ViewportControlsState) {
this.setState({ isSettingsExpanded: !this.state.isSettingsExpanded, isHelpExpanded: false }); return (e?: React.MouseEvent<HTMLButtonElement>) => {
this.setState({ ...this.allCollapsedState, [panel]: !this.state[panel] });
e?.currentTarget.blur(); e?.currentTarget.blur();
};
} }
toggleHelpExpanded = (e?: React.MouseEvent<HTMLButtonElement>) => { toggleSettingsExpanded = this.toggle('isSettingsExpanded');
this.setState({ isSettingsExpanded: false, isHelpExpanded: !this.state.isHelpExpanded }); toggleHelpExpanded = this.toggle('isHelpExpanded');
e?.currentTarget.blur(); toggleScreenshotExpanded = this.toggle('isScreenshotExpanded');
}
toggleControls = () => { toggleControls = () => {
PluginCommands.Layout.Update.dispatch(this.plugin, { state: { showControls: !this.plugin.layout.state.showControls } }); PluginCommands.Layout.Update.dispatch(this.plugin, { state: { showControls: !this.plugin.layout.state.showControls } });
...@@ -89,7 +95,7 @@ export class ViewportControls extends PluginUIComponent<ViewportControlsProps, V ...@@ -89,7 +95,7 @@ export class ViewportControls extends PluginUIComponent<ViewportControlsProps, V
</div> </div>
<div> <div>
<div className='msp-semi-transparent-background' /> <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> <div>
<div className='msp-semi-transparent-background' /> <div className='msp-semi-transparent-background' />
...@@ -107,19 +113,15 @@ export class ViewportControls extends PluginUIComponent<ViewportControlsProps, V ...@@ -107,19 +113,15 @@ export class ViewportControls extends PluginUIComponent<ViewportControlsProps, V
<HelpContent /> <HelpContent />
</ControlGroup> </ControlGroup>
</div>} */} </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'> {this.state.isSettingsExpanded && <div className='msp-viewport-controls-panel'>
<ControlGroup header='Basic Settings' initialExpanded={true} hideExpander={true} hideOffset={true} onHeaderClick={this.toggleSettingsExpanded} topRightIcon='off'> <ControlGroup header='Basic Settings' initialExpanded={true} hideExpander={true} hideOffset={true} onHeaderClick={this.toggleSettingsExpanded} topRightIcon='off'>
<SimpleSettingsControl /> <SimpleSettingsControl />
</ControlGroup> </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>}
</div> </div>
} }
......
/**
* 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
...@@ -9,11 +9,33 @@ import { PluginContext } from '../context'; ...@@ -9,11 +9,33 @@ import { PluginContext } from '../context';
import { ImagePass } from '../../mol-canvas3d/passes/image'; import { ImagePass } from '../../mol-canvas3d/passes/image';
import { StateSelection } from '../../mol-state'; import { StateSelection } from '../../mol-state';
import { PluginStateObject } from '../state/objects'; import { PluginStateObject } from '../state/objects';
import { Task } from '../../mol-task'; import { Task, RuntimeContext } from '../../mol-task';
import { canvasToBlob } from '../../mol-canvas3d/util'; import { canvasToBlob } from '../../mol-canvas3d/util';
import { download } from '../../mol-util/download'; 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 { 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() { private getCanvasSize() {
return { return {
width: this.plugin.canvas3d?.webgl.gl.drawingBufferWidth || 0, width: this.plugin.canvas3d?.webgl.gl.drawingBufferWidth || 0,
...@@ -21,7 +43,16 @@ export class ViewportScreenshotWrapper { ...@@ -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; private _imagePass: ImagePass;
...@@ -44,28 +75,42 @@ export class ViewportScreenshotWrapper { ...@@ -44,28 +75,42 @@ export class ViewportScreenshotWrapper {
return `${idString || 'molstar-image'}.png` return `${idString || 'molstar-image'}.png`
} }
private downloadTask() { private canvas = function () {
return Task.create('Download Image', async ctx => { const canvas = document.createElement('canvas');
const { width, height } = this.size return canvas;
if (width <= 0 || height <= 0) return }();
private async draw(ctx: RuntimeContext) {
const { width, height } = this.getSize();
if (width <= 0 || height <= 0) return;
await ctx.update('Rendering image...') await ctx.update('Rendering image...')
const imageData = this.imagePass.getImageData(width, height) const imageData = this.imagePass.getImageData(width, height);
await ctx.update('Encoding image...') await ctx.update('Encoding image...')
const canvas = document.createElement('canvas') const canvas = this.canvas
canvas.width = imageData.width canvas.width = imageData.width
canvas.height = imageData.height canvas.height = imageData.height
const canvasCtx = canvas.getContext('2d') const canvasCtx = canvas.getContext('2d')
if (!canvasCtx) throw new Error('Could not create canvas 2d context') if (!canvasCtx) throw new Error('Could not create canvas 2d context')
canvasCtx.putImageData(imageData, 0, 0) canvasCtx.putImageData(imageData, 0, 0)
return;
}
private downloadTask() {
return Task.create('Download Image', async ctx => {
this.draw(ctx);
await ctx.update('Downloading image...') await ctx.update('Downloading image...')
const blob = await canvasToBlob(canvas, 'png') const blob = await canvasToBlob(this.canvas, 'png')
download(blob, this.getFilename()) download(blob, this.getFilename())
}) })
} }
async imageData() {
await this.draw(SyncRuntimeContext)
return this.canvas.toDataURL();
}
download() { download() {
this.plugin.runTask(this.downloadTask()); this.plugin.runTask(this.downloadTask());
} }
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment