Browse Source

screenshot cropping

David Sehnal 4 years ago
parent
commit
c6e0ec1c06

+ 1 - 33
src/examples/alpha-orbitals/controls.tsx

@@ -5,10 +5,10 @@
  */
 
 import * as React from 'react';
-import { useEffect, useState } from 'react';
 import * as ReactDOM from 'react-dom';
 import { AlphaOrbitalsExample } from '.';
 import { ParameterControls } from '../../mol-plugin-ui/controls/parameters';
+import { useBehavior } from '../../mol-plugin-ui/hooks/use-behavior';
 import { PluginContextContainer } from '../../mol-plugin-ui/plugin';
 
 export function mountControls(orbitals: AlphaOrbitalsExample, parent: Element) {
@@ -22,36 +22,4 @@ function Controls({ orbitals }: { orbitals: AlphaOrbitalsExample }) {
     const values = useBehavior(orbitals.state);
 
     return <ParameterControls params={params as any} values={values} onChangeValues={(vs: any) => orbitals.state.next(vs)} />;
-}
-
-
-interface Behavior<T> {
-    value: T;
-    subscribe(f: (v: T) => void): { unsubscribe(): void };
-}
-
-export function useBehavior<T>(s: Behavior<T>): T;
-// eslint-disable-next-line
-export function useBehavior<T>(s: Behavior<T> | undefined): T | undefined;
-// eslint-disable-next-line
-export function useBehavior<T>(s: Behavior<T> | undefined): T | undefined {
-    const [value, setValue] = useState(s?.value);
-
-    useEffect(() => {
-        if (!s) return;
-        let fst = true;
-        const sub = s.subscribe((v) => {
-            if (fst) {
-                fst = false;
-                if (v !== value) setValue(v);
-            } else setValue(v);
-        });
-
-        return () => {
-            sub.unsubscribe();
-        };
-        // eslint-disable-next-line
-    }, [s]);
-
-    return value;
 }

+ 5 - 0
src/mol-canvas3d/canvas3d.ts

@@ -118,6 +118,7 @@ interface Canvas3D {
     notifyDidDraw: boolean,
     readonly didDraw: BehaviorSubject<now.Timestamp>
     readonly reprCount: BehaviorSubject<number>
+    readonly resized: BehaviorSubject<any>
 
     handleResize(): void
     /** Focuses camera on scene's bounding sphere, centered and zoomed. */
@@ -541,6 +542,8 @@ namespace Canvas3D {
             draw(true);
         });
 
+        const resized = new BehaviorSubject<any>(0);
+
         return {
             webgl,
 
@@ -591,6 +594,7 @@ namespace Canvas3D {
                 updateViewport();
                 syncViewport();
                 requestDraw(true);
+                resized.next(+new Date());
             },
             requestCameraReset: options => {
                 nextCameraResetDuration = options?.durationMs;
@@ -603,6 +607,7 @@ namespace Canvas3D {
             set notifyDidDraw(v: boolean) { notifyDidDraw = v; },
             didDraw,
             reprCount,
+            resized,
             setProps: (properties, doNotRequestDraw = false) => {
                 const props: PartialCanvas3DProps = typeof properties === 'function'
                     ? produce(getProps(), properties)

+ 38 - 0
src/mol-plugin-ui/hooks/use-behavior.ts

@@ -0,0 +1,38 @@
+/**
+ * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { useEffect, useState } from 'react';
+
+interface Behavior<T> {
+    value: T;
+    subscribe(f: (v: T) => void): { unsubscribe(): void };
+}
+
+export function useBehavior<T>(s: Behavior<T>): T;
+// eslint-disable-next-line
+export function useBehavior<T>(s: Behavior<T> | undefined): T | undefined;
+// eslint-disable-next-line
+export function useBehavior<T>(s: Behavior<T> | undefined): T | undefined {
+    const [value, setValue] = useState(s?.value);
+
+    useEffect(() => {
+        if (!s) return;
+        let fst = true;
+        const sub = s.subscribe((v) => {
+            if (fst) {
+                fst = false;
+                if (v !== value) setValue(v);
+            } else setValue(v);
+        });
+
+        return () => {
+            sub.unsubscribe();
+        };
+        // eslint-disable-next-line
+    }, [s]);
+
+    return value;
+}

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

@@ -412,5 +412,6 @@
         display: block;
         text-align: center;
         font-size: 80%;
+        line-height: 15px;
     }
 }

+ 178 - 14
src/mol-plugin-ui/viewport/screenshot.tsx

@@ -15,7 +15,12 @@ import { Button, ExpandGroup } from '../controls/common';
 import { CameraHelperProps } from '../../mol-canvas3d/helper/camera-helper';
 import { PluginCommands } from '../../mol-plugin/commands';
 import { StateExportImportControls, LocalStateSnapshotParams } from '../state/snapshots';
-import { CopySvg, GetAppSvg } from '../controls/icons';
+import { CopySvg, GetAppSvg, RefreshSvg } from '../controls/icons';
+import { PluginContext } from '../../mol-plugin/context';
+import { useBehavior } from '../hooks/use-behavior';
+import { Viewport } from '../../mol-canvas3d/camera/util';
+import { useEffect, useRef, useState } from 'react';
+import { equalEps } from '../../mol-math/linear-algebra/3d/common';
 
 interface ImageControlsState {
     showPreview: boolean
@@ -42,23 +47,15 @@ export class DownloadScreenshotControls extends PluginUIComponent<{ close: () =>
         const ctx = this.canvasRef.current.getContext('2d');
         if (!ctx) return;
 
-        const a0 = width / height;
-
         const target = this.canvasRef.current;
-        const w = this.canvasRef.current.clientWidth;
-        const h = this.canvasRef.current.clientHeight;
+        const w = target.clientWidth;
+        const h = target.clientHeight;
         target.width = w;
         target.height = h;
-        const a1 = w / h;
 
         ctx.clearRect(0, 0, w, h);
-        if (a0 <= a1) {
-            const t = h * a0;
-            ctx.drawImage(canvas, Math.round((w - t) / 2), 0, Math.round(t), h);
-        } else {
-            const t = w / a0;
-            ctx.drawImage(canvas, 0, Math.round((h - t) / 2), w, Math.round(t));
-        }
+        const frame = getViewportFrame(width, height, w, h);
+        ctx.drawImage(canvas, frame.x, frame.y, frame.width, frame.height);
     }
 
     private download = () => {
@@ -99,6 +96,10 @@ export class DownloadScreenshotControls extends PluginUIComponent<{ close: () =>
             this.updateQueue.next();
         });
 
+        this.subscribe(this.plugin.helpers.viewportScreenshot!.behaviors.relativeCrop, () => {
+            this.forceUpdate();
+        });
+
         this.preview();
     }
 
@@ -125,10 +126,20 @@ export class DownloadScreenshotControls extends PluginUIComponent<{ close: () =>
 
     render() {
         const values = this.plugin.helpers.viewportScreenshot!.values;
+        const crop = this.plugin.helpers.viewportScreenshot!.relativeCrop;
 
         return <div>
             <div className='msp-image-preview'>
-                <canvas ref={this.canvasRef} onClick={this.onCanvasClick} onContextMenu={this.onCanvasClick} style={{ width: '100%', height: '180px' }} className={values.transparent ? 'msp-transparent-screenshot' : void 0}></canvas><br />
+                <div style={{ position: 'relative', width: '100%', height: '100%' }}>
+                    <canvas ref={this.canvasRef} onClick={this.onCanvasClick} onContextMenu={this.onCanvasClick} style={{ width: '100%', height: '180px' }} className={values.transparent ? 'msp-transparent-screenshot' : void 0}></canvas>
+                    <ViewportFrame plugin={this.plugin} canvasRef={this.canvasRef} />
+                </div>
+                <span>
+                    Drag the frame to crop the image.
+                    {!isFullFrame(crop) && <Button icon={RefreshSvg} title='Reset Crop' inline
+                        style={{ width: 'auto', background: 'transparent', height: '15px', lineHeight: '15px', padding: '0 4px', marginLeft: '8px', border: 'none' }}
+                        onClick={() => this.plugin.helpers.viewportScreenshot!.behaviors.relativeCrop.next({ x: 0, y: 0, width: 1, height: 1 })} />}
+                </span>
             </div>
             <div className='msp-flex-row'>
                 {!!(navigator.clipboard as any).write && <Button icon={CopySvg} onClick={this.copy} disabled={this.state.isDisabled}>Copy</Button>}
@@ -143,4 +154,157 @@ export class DownloadScreenshotControls extends PluginUIComponent<{ close: () =>
             </ExpandGroup>
         </div>;
     }
+}
+
+function isFullFrame(crop: Viewport) {
+    return equalEps(crop.x, 0, 1e-5) && equalEps(crop.y, 0, 1e-5) && equalEps(crop.width, 1, 1e-5) && equalEps(crop.height, 1, 1e-5);
+}
+
+export function ViewportFrame({ plugin, canvasRef }: { plugin: PluginContext, canvasRef: React.RefObject<HTMLCanvasElement> }) {
+    const helper = plugin.helpers.viewportScreenshot;
+    const params = useBehavior(helper?.behaviors.values);
+    const crop = useBehavior(helper?.behaviors.relativeCrop);
+    const cropFrameRef = useRef<Viewport>({ x: 0, y: 0, width: 0, height: 0 });
+
+    useBehavior(params?.resolution.name === 'viewport' ? plugin.canvas3d?.resized : void 0);
+    useEffect(() => {
+        return () => {
+            if (onMove) document.removeEventListener('mousemove', onMove);
+            if (onEnd) document.removeEventListener('mouseup', onEnd);
+        };
+    }, []);
+
+    const [drag, setDrag] = React.useState<string>('');
+    const [start, setStart] = useState([0, 0]);
+    const [current, setCurrent] = useState([0, 0]);
+
+    if (!helper || !crop || !canvasRef.current) return null;
+
+    const { width, height } = helper.getSizeAndViewport();
+    const canvas = canvasRef.current;
+
+    const frame = getViewportFrame(width, height, canvas.clientWidth, canvas.clientHeight);
+
+    const cropFrame: Viewport = {
+        x: frame.x + Math.floor(frame.width * crop.x),
+        y: frame.y + Math.floor(frame.height * crop.y),
+        width: Math.ceil(frame.width * crop.width),
+        height: Math.ceil(frame.height * crop.height)
+    };
+
+    const rectCrop = toRect(cropFrame);
+    const rectFrame = toRect(frame);
+
+    if (drag === 'move') {
+        rectCrop.l += current[0] - start[0];
+        rectCrop.r += current[0] - start[0];
+        rectCrop.t += current[1] - start[1];
+        rectCrop.b += current[1] - start[1];
+    } else if (drag) {
+        if (drag.indexOf('left') >= 0) {
+            rectCrop.l += current[0] - start[0];
+        } else if (drag.indexOf('right') >= 0) {
+            rectCrop.r += current[0] - start[0];
+        }
+
+        if (drag.indexOf('top') >= 0) {
+            rectCrop.t += current[1] - start[1];
+        } else if (drag.indexOf('bottom') >= 0) {
+            rectCrop.b += current[1] - start[1];
+        }
+    }
+
+    if (rectCrop.l > rectCrop.r) {
+        const t = rectCrop.l;
+        rectCrop.l = rectCrop.r;
+        rectCrop.r = t;
+    }
+
+    if (rectCrop.t > rectCrop.b) {
+        const t = rectCrop.t;
+        rectCrop.t = rectCrop.b;
+        rectCrop.b = t;
+    }
+
+    const pad = 40;
+    rectCrop.l = Math.min(rectFrame.r - pad, Math.max(rectFrame.l, rectCrop.l));
+    rectCrop.r = Math.max(rectFrame.l + pad, Math.min(rectFrame.r, rectCrop.r));
+    rectCrop.t = Math.min(rectFrame.b - pad, Math.max(rectFrame.t, rectCrop.t));
+    rectCrop.b = Math.max(rectFrame.t + pad, Math.min(rectFrame.b, rectCrop.b));
+
+    cropFrame.x = rectCrop.l;
+    cropFrame.y = rectCrop.t;
+    cropFrame.width = rectCrop.r - rectCrop.l;
+    cropFrame.height = rectCrop.b - rectCrop.t;
+
+    cropFrameRef.current = cropFrame;
+
+    const onMove = (e: MouseEvent) => {
+        e.preventDefault();
+        setCurrent([e.pageX, e.pageY]);
+    };
+
+    const onStart = (e: React.MouseEvent<HTMLElement>) => {
+        e.preventDefault();
+        setDrag(e.currentTarget.getAttribute('data-drag')! as any);
+        const p = [e.pageX, e.pageY];
+        setStart(p);
+        setCurrent(p);
+        document.addEventListener('mouseup', onEnd);
+        document.addEventListener('mousemove', onMove);
+    };
+
+    const onEnd = () => {
+        document.removeEventListener('mouseup', onEnd);
+        document.removeEventListener('mousemove', onMove);
+
+        const cropFrame = cropFrameRef.current;
+        helper?.behaviors.relativeCrop.next({
+            x: (cropFrame.x - frame.x) / frame.width,
+            y: (cropFrame.y - frame.y) / frame.height,
+            width: cropFrame.width / frame.width,
+            height: cropFrame.height / frame.height
+        });
+        setDrag('');
+        const p = [0, 0];
+        setStart(p);
+        setCurrent(p);
+    };
+
+    const d = 4;
+    const border = `6px solid rgba(255, 87, 45, 0.75)`;
+    const transparent = 'transparent';
+
+    return <>
+        <div style={{ position: 'absolute', left: frame.x, top: frame.y, width: frame.width, height: frame.height, border: '1px solid rgba(0, 0, 0, 0.25)' }} />
+
+        <div data-drag='move' style={{ position: 'absolute', left: cropFrame.x, top: cropFrame.y, width: cropFrame.width, height: cropFrame.height, border, cursor: 'move' }} onMouseDown={onStart} draggable={false} />
+
+        <div data-drag='left' style={{ position: 'absolute', left: cropFrame.x - d, top: cropFrame.y + d, width: 4 * d, height: cropFrame.height - d, background: transparent, cursor: 'w-resize' }} onMouseDown={onStart} draggable={false} />
+        <div data-drag='right' style={{ position: 'absolute', left: rectCrop.r - 2 * d, top: cropFrame.y, width: 4 * d, height: cropFrame.height - d, background: transparent, cursor: 'w-resize' }} onMouseDown={onStart} draggable={false} />
+        <div data-drag='top' style={{ position: 'absolute', left: cropFrame.x - d, top: cropFrame.y - d, width: cropFrame.width + 2 * d, height: 4 * d, background: transparent, cursor: 'n-resize' }} onMouseDown={onStart} draggable={false} />
+        <div data-drag='bottom' style={{ position: 'absolute', left: cropFrame.x - d, top: rectCrop.b - 2 * d, width: cropFrame.width + 2 * d, height: 4 * d, background: transparent, cursor: 'n-resize' }} onMouseDown={onStart} draggable={false} />
+
+        <div data-drag='top, left' style={{ position: 'absolute', left: rectCrop.l - d, top: rectCrop.t - d, width: 4 * d, height: 4 * d, background: transparent, cursor: 'nw-resize' }} onMouseDown={onStart} draggable={false} />
+        <div data-drag='bottom, right' style={{ position: 'absolute', left: rectCrop.r - 2 * d, top: rectCrop.b - 2 * d, width: 4 * d, height: 4 * d, background: transparent, cursor: 'nw-resize' }} onMouseDown={onStart} draggable={false} />
+        <div data-drag='top, right' style={{ position: 'absolute', left: rectCrop.r - 2 * d, top: rectCrop.t - d, width: 4 * d, height: 4 * d, background: transparent, cursor: 'ne-resize' }} onMouseDown={onStart} draggable={false} />
+        <div data-drag='bottom, left' style={{ position: 'absolute', left: rectCrop.l - d, top: rectCrop.b - 2 * d, width: 4 * d, height: 4 * d, background: transparent, cursor: 'ne-resize' }} onMouseDown={onStart} draggable={false} />
+    </>;
+}
+
+function toRect(viewport: Viewport) {
+    return { l: viewport.x, t: viewport.y, r: viewport.x + viewport.width, b: viewport.y + viewport.height };
+}
+
+function getViewportFrame(srcWidth: number, srcHeight: number, w: number, h: number): Viewport {
+    const a0 = srcWidth / srcHeight;
+    const a1 = w / h;
+
+    if (a0 <= a1) {
+        const t = h * a0;
+        return { x: Math.round((w - t) / 2), y: 0, width: Math.round(t), height: h };
+    } else {
+        const t = w / a0;
+        return { x: 0, y: Math.round((h - t) / 2), width: w, height: Math.round(t) };
+    }
 }

+ 24 - 3
src/mol-plugin/util/viewport-screenshot.ts

@@ -1,3 +1,4 @@
+import { Viewport } from '../../mol-canvas3d/camera/util';
 /**
  * Copyright (c) 2019-2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
@@ -63,13 +64,18 @@ class ViewportScreenshotHelper extends PluginComponent {
             transparent: this.params.transparent.defaultValue,
             axes: { name: 'off', params: {} },
             resolution: this.params.resolution.defaultValue
-        })
+        }),
+        relativeCrop: this.ev.behavior<Viewport>({ x: 0.33, y: 0.33, width: 0.45, height: 0.45 })
     };
 
     get values() {
         return this.behaviors.values.value;
     }
 
+    get relativeCrop() {
+        return this.behaviors.relativeCrop.value;
+    }
+
     private getCanvasSize() {
         return {
             width: this.plugin.canvas3d?.webgl.gl.drawingBufferWidth || 0,
@@ -160,8 +166,22 @@ class ViewportScreenshotHelper extends PluginComponent {
         return { canvas, width: w, height: h };
     }
 
-    private async draw(ctx: RuntimeContext) {
+    getSizeAndViewport() {
         const { width, height } = this.getSize();
+        const crop = this.relativeCrop;
+        const viewport: Viewport = {
+            x: Math.floor(crop.x * width),
+            y: Math.floor(crop.y * height),
+            width: Math.ceil(crop.width * width),
+            height: Math.ceil(crop.height * height)
+        };
+        if (viewport.width + viewport.x > width) viewport.width = width - viewport.x;
+        if (viewport.height + viewport.y > height) viewport.height = height - viewport.y;
+        return { width, height, viewport };
+    }
+
+    private async draw(ctx: RuntimeContext) {
+        const { width, height, viewport } = this.getSizeAndViewport();
         if (width <= 0 || height <= 0) return;
 
         await ctx.update('Rendering image...');
@@ -171,7 +191,8 @@ class ViewportScreenshotHelper extends PluginComponent {
             // TODO: optimize because this creates a copy of a large object!
             postprocessing: this.plugin.canvas3d!.props.postprocessing
         });
-        const imageData = this.imagePass.getImageData(width, height);
+
+        const imageData = this.imagePass.getImageData(width, height, viewport);
 
         await ctx.update('Encoding image...');
         const canvas = this.canvas;