diff --git a/src/mol-canvas3d/util.ts b/src/mol-canvas3d/util.ts index 6cae476a114ea1402357c74ed9000763c9b0b52d..d1e331e5bf2aabf0f91a4bc38b882c2d44d53e67 100644 --- a/src/mol-canvas3d/util.ts +++ b/src/mol-canvas3d/util.ts @@ -4,16 +4,57 @@ * @author Alexander Rose <alexander.rose@weirdbyte.de> */ -/** resize canvas to container element */ +/** Set canvas size taking `devicePixelRatio` into account */ +export function setCanvasSize(canvas: HTMLCanvasElement, width: number, height: number) { + canvas.width = window.devicePixelRatio * width + canvas.height = window.devicePixelRatio * height + Object.assign(canvas.style, { width: `${width}px`, height: `${height}px` }) +} + +/** Resize canvas to container element taking `devicePixelRatio` into account */ export function resizeCanvas (canvas: HTMLCanvasElement, container: Element) { - let w = window.innerWidth - let h = window.innerHeight + let width = window.innerWidth + let height = window.innerHeight if (container !== document.body) { let bounds = container.getBoundingClientRect() - w = bounds.right - bounds.left - h = bounds.bottom - bounds.top + width = bounds.right - bounds.left + height = bounds.bottom - bounds.top + } + setCanvasSize(canvas, width, height) +} + +function _canvasToBlob(canvas: HTMLCanvasElement, callback: BlobCallback, type?: string, quality?: any) { + const bin = atob(canvas.toDataURL(type, quality).split(',')[1]) + const len = bin.length + const len32 = len >> 2 + const a8 = new Uint8Array(len) + const a32 = new Uint32Array( a8.buffer, 0, len32 ) + + let j = 0 + for (let i = 0; i < len32; ++i) { + a32[i] = bin.charCodeAt(j++) | + bin.charCodeAt(j++) << 8 | + bin.charCodeAt(j++) << 16 | + bin.charCodeAt(j++) << 24 } - canvas.width = window.devicePixelRatio * w - canvas.height = window.devicePixelRatio * h - Object.assign(canvas.style, { width: `${w}px`, height: `${h}px` }) + + let tailLength = len & 3; + while (tailLength--) a8[j] = bin.charCodeAt(j++) + + callback(new Blob([a8], { type: type || 'image/png' })); +} + +export async function canvasToBlob(canvas: HTMLCanvasElement, type?: string, quality?: any): Promise<Blob> { + return new Promise((resolve, reject) => { + const callback = (blob: Blob | null) => { + if (blob) resolve(blob) + else reject('no blob returned') + } + + if (!HTMLCanvasElement.prototype.toBlob) { + _canvasToBlob(canvas, callback, type, quality) + } else { + canvas.toBlob(callback, type, quality) + } + }) } \ No newline at end of file diff --git a/src/mol-gl/webgl/context.ts b/src/mol-gl/webgl/context.ts index 90a916f59628444d056ee603010d9dd939889911..b9f59b2967a85347cca23b844a388689e6eec646 100644 --- a/src/mol-gl/webgl/context.ts +++ b/src/mol-gl/webgl/context.ts @@ -190,6 +190,7 @@ export interface WebGLContext { readonly framebufferCache: FramebufferCache readonly maxTextureSize: number + readonly maxRenderbufferSize: number readonly maxDrawBuffers: number unbindFramebuffer: () => void @@ -212,6 +213,7 @@ export function createContext(gl: GLRenderingContext): WebGLContext { const parameters = { maxTextureSize: gl.getParameter(gl.MAX_TEXTURE_SIZE) as number, + maxRenderbufferSize: gl.getParameter(gl.MAX_RENDERBUFFER_SIZE) as number, maxDrawBuffers: isWebGL2(gl) ? gl.getParameter(gl.MAX_DRAW_BUFFERS) as number : 0, maxVertexTextureImageUnits: gl.getParameter(gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS) as number, } @@ -274,6 +276,7 @@ export function createContext(gl: GLRenderingContext): WebGLContext { framebufferCache, get maxTextureSize () { return parameters.maxTextureSize }, + get maxRenderbufferSize () { return parameters.maxRenderbufferSize }, get maxDrawBuffers () { return parameters.maxDrawBuffers }, unbindFramebuffer: () => unbindFramebuffer(gl), diff --git a/src/mol-plugin/skin/base/components/controls.scss b/src/mol-plugin/skin/base/components/controls.scss index f0fc1cbae0595c32c6a473f0f9abe25457571261..10204bf1c3fd6022ad4b0e05b746fc3274fa0c65 100644 --- a/src/mol-plugin/skin/base/components/controls.scss +++ b/src/mol-plugin/skin/base/components/controls.scss @@ -362,4 +362,25 @@ margin: 0 ($control-spacing / 2); } } +} + +.msp-image-preview { + 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; + } } \ No newline at end of file diff --git a/src/mol-plugin/ui/image.tsx b/src/mol-plugin/ui/image.tsx new file mode 100644 index 0000000000000000000000000000000000000000..726761ca81dce07d7aa21d5084e3fde50624d8be --- /dev/null +++ b/src/mol-plugin/ui/image.tsx @@ -0,0 +1,204 @@ +/** + * 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 { ImagePass } from '../../mol-canvas3d/passes/image'; +import { download } from '../../mol-util/download'; +import { setCanvasSize, canvasToBlob } from '../../mol-canvas3d/util'; +import { Task } from '../../mol-task'; + +interface ImageControlsState extends CollapsableState { + showPreview: boolean + + size: 'canvas' | 'custom' + width: number + height: number +} + +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 + + constructor(props: P, context?: any) { + super(props, context) + + this.subscribe(this.plugin.events.canvas3d.initialized, () => this.forceUpdate()) + } + + private getSize() { + return this.state.size === 'canvas' ? { + 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 + const imageData = this.imagePass.getImageData(w * pixelRatio, h * pixelRatio) + this.canvasContext.putImageData(imageData, 0, 0) + } + + private downloadTask = () => { + return Task.create('Download Image', async ctx => { + const { width, height } = this.getSize() + if (width <= 0 || height <= 0) return + + await ctx.update('Rendering image...') + const imageData = this.imagePass.getImageData(width, height) + + 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('Downloading image...') + const blob = await canvasToBlob(canvas) + download(blob, 'molstar-image') + }) + } + + private download = () => { + this.plugin.runTask(this.downloadTask()) + } + + 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.handlePreview() + } + + componentDidMount() { + this.imagePass = this.plugin.canvas3d.getImagePass() + this.imagePass.setProps({ + multiSample: { mode: 'on', sampleLevel: 2 }, + postprocessing: this.plugin.canvas3d.props.postprocessing + }) + + 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()) + } + + 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: 1, max, step: 1 }), + height: PD.Numeric(height, { min: 1, 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 + } as S + } + + protected renderControls() { + return <div> + <div className='msp-control-row'> + <button className='msp-btn msp-btn-block' onClick={this.download}>Download</button> + </div> + <ParameterControls params={this.params} values={this.values} onChange={this.setProps} /> + <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 23e3c3ad373b2d5f8aee5f160d516491f3f0e629..b1b451881be136ab95096bc9dfb55751728ef7fa 100644 --- a/src/mol-plugin/ui/plugin.tsx +++ b/src/mol-plugin/ui/plugin.tsx @@ -22,6 +22,7 @@ import { StateTransform } from '../../mol-state'; import { UpdateTransformControl } from './state/update-transform'; import { SequenceView } from './sequence'; import { Toasts } from './toast'; +import { ImageControls } from './image'; export class Plugin extends React.Component<{ plugin: PluginContext }, {}> { region(kind: 'left' | 'right' | 'bottom' | 'main', element: JSX.Element) { @@ -129,6 +130,7 @@ export class ControlsWrapper extends PluginUIComponent { {/* <AnimationControlsWrapper /> */} {/* <CameraSnapshots /> */} <StructureToolsWrapper /> + <ImageControls /> <StateSnapshots /> </div>; }