瀏覽代碼

screenshot controls

David Sehnal 4 年之前
父節點
當前提交
e51fe83800

+ 247 - 0
src/mol-plugin-ui/controls/screenshot.tsx

@@ -0,0 +1,247 @@
+/**
+ * Copyright (c) 2019-2020 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 { useEffect, useRef, useState } from 'react';
+import { Observable, Subject, Subscription } from 'rxjs';
+import { debounceTime } from 'rxjs/operators';
+import { Viewport } from '../../mol-canvas3d/camera/util';
+import { PluginContext } from '../../mol-plugin/context';
+import { ViewportScreenshotHelper } from '../../mol-plugin/util/viewport-screenshot';
+import { useBehavior } from '../hooks/use-behavior';
+
+export function ScreenshotPreview({ plugin, suspend, frameColor = 'rgba(255, 87, 45, 0.75)' }: { plugin: PluginContext, suspend?: boolean, frameColor?: string }) {
+    const helper = plugin.helpers.viewportScreenshot!;
+    const canvasRef = useRef<HTMLCanvasElement | null>(null);
+    const suspendRef = useRef(suspend);
+
+    useEffect(() => {
+        suspendRef.current = suspend;
+    }, [suspend]);
+
+    useEffect(() => {
+        let paused = false;
+        const updateQueue = new Subject();
+        const subs: Subscription[] = [];
+
+        function subscribe<T>(xs: Observable<T> | undefined, f: (v: T) => any) {
+            if (!xs) return;
+            subs.push(xs.subscribe(f));
+        }
+
+        function preview() {
+            if (!suspendRef.current && !paused && canvasRef.current) {
+                drawPreview(helper, canvasRef.current);
+            }
+        }
+
+        subscribe(updateQueue.pipe(debounceTime(33)), preview);
+        subscribe(plugin.events.canvas3d.settingsUpdated, () => updateQueue.next());
+
+        subscribe(plugin.canvas3d?.didDraw.pipe(debounceTime(150)), () => {
+            if (paused) return;
+            updateQueue.next();
+        });
+
+        subscribe(plugin.state.data.behaviors.isUpdating, v => {
+            paused = v;
+            if (!v) updateQueue.next();
+        });
+
+        subscribe(helper.behaviors.values, () => updateQueue.next());
+        subscribe(helper.behaviors.cropParams, () => updateQueue.next());
+
+        preview();
+
+        return () => subs.forEach(s => s.unsubscribe());
+    }, [helper]);
+
+    return <>
+        <div style={{ position: 'relative', width: '100%', height: '100%' }}>
+            <canvas ref={canvasRef} onContextMenu={e => { e.preventDefault(); e.stopPropagation(); }} style={{ display: 'block', width: '100%', height: '100%' }}></canvas>
+            <ViewportFrame plugin={plugin} canvasRef={canvasRef} color={frameColor} />
+        </div>
+    </>;
+}
+
+function drawPreview(helper: ViewportScreenshotHelper, target: HTMLCanvasElement) {
+    const { canvas, width, height } = helper.getPreview()!;
+    const ctx = target.getContext('2d');
+    if (!ctx) return;
+
+    const w = target.clientWidth;
+    const h = target.clientHeight;
+    target.width = w;
+    target.height = h;
+
+    ctx.clearRect(0, 0, w, h);
+    const frame = getViewportFrame(width, height, w, h);
+    if (helper.values.transparent) {
+        // must be an odd number
+        const s = 13;
+        for (let i = 0; i < frame.width; i += s) {
+            for (let j = 0; j < frame.height; j += s) {
+                ctx.fillStyle = (i + j) % 2 ? '#ffffff' : '#bfbfbf';
+
+                const x = frame.x + i, y = frame.y + j;
+                const w = i + s > frame.width ? frame.width - i : s;
+                const h = j + s > frame.height ? frame.height - j : s;
+                ctx.fillRect(x, y, w, h);
+            }
+        }
+    }
+    ctx.drawImage(canvas, frame.x, frame.y, frame.width, frame.height);
+}
+
+function ViewportFrame({ plugin, canvasRef, color = 'rgba(255, 87, 45, 0.75)' }: { plugin: PluginContext, canvasRef: React.RefObject<HTMLCanvasElement>, color?: string }) {
+    const helper = plugin.helpers.viewportScreenshot;
+    const params = useBehavior(helper?.behaviors.values!);
+    const cropParams = useBehavior(helper?.behaviors.cropParams!);
+    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);
+
+    const [drag, setDrag] = React.useState<string>('');
+    const [start, setStart] = useState([0, 0]);
+    const [current, setCurrent] = useState([0, 0]);
+
+    if (!helper || !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 + 1;
+    cropFrame.height = rectCrop.b - rectCrop.t + 1;
+
+    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);
+        window.addEventListener('mouseup', onEnd);
+        window.addEventListener('mousemove', onMove);
+    };
+
+    const onEnd = () => {
+        window.removeEventListener('mouseup', onEnd);
+        window.removeEventListener('mousemove', onMove);
+
+        const cropFrame = cropFrameRef.current;
+        if (cropParams.auto) {
+            helper.behaviors.cropParams.next({ ...cropParams, auto: false });
+        }
+        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 contextMenu = (e: React.MouseEvent) => {
+        e.preventDefault();
+        e.stopPropagation();
+    };
+
+    const d = 4;
+    const border = `3px solid ${color}`;
+    const transparent = 'transparent';
+
+    return <>
+        <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} onContextMenu={contextMenu} />
+
+        <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} onContextMenu={contextMenu} />
+        <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} onContextMenu={contextMenu} />
+        <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} onContextMenu={contextMenu} />
+        <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} onContextMenu={contextMenu} />
+
+        <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} onContextMenu={contextMenu} />
+        <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} onContextMenu={contextMenu} />
+        <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} onContextMenu={contextMenu} />
+        <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} onContextMenu={contextMenu} />
+    </>;
+}
+
+function toRect(viewport: Viewport) {
+    return { l: viewport.x, t: viewport.y, r: viewport.x + viewport.width - 1, b: viewport.y + viewport.height - 1 };
+}
+
+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) };
+    }
+}

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

@@ -388,15 +388,10 @@
     position: relative;
     background: $default-background;
     margin-top: 1px;
-    text-align: center;
     padding: $control-spacing;
 
     canvas {
         @include user-select(none);
-
-        max-height: 180px;
-        max-width: 100%;
-        display: 'block';
     }
 
     > span {

+ 20 - 255
src/mol-plugin-ui/viewport/screenshot.tsx

@@ -6,29 +6,19 @@
  */
 
 import * as React from 'react';
-import { useRef, useState } from 'react';
-import { Subject } from 'rxjs';
-import { debounceTime } from 'rxjs/operators';
-import { Viewport } from '../../mol-canvas3d/camera/util';
-import { CameraHelperProps } from '../../mol-canvas3d/helper/camera-helper';
-import { equalEps } from '../../mol-math/linear-algebra/3d/common';
 import { PluginCommands } from '../../mol-plugin/commands';
 import { PluginContext } from '../../mol-plugin/context';
-import { ViewportScreenshotHelper, ViewportScreenshotHelperParams } from '../../mol-plugin/util/viewport-screenshot';
 import { PluginUIComponent } from '../base';
 import { Button, ExpandGroup, ToggleButton } from '../controls/common';
 import { CopySvg, CropFreeSvg, CropOrginalSvg, CropSvg, GetAppSvg } from '../controls/icons';
 import { ParameterControls } from '../controls/parameters';
+import { ScreenshotPreview } from '../controls/screenshot';
 import { useBehavior } from '../hooks/use-behavior';
 import { LocalStateSnapshotParams, StateExportImportControls } from '../state/snapshots';
 
 interface ImageControlsState {
     showPreview: boolean
     isDisabled: boolean
-
-    resolution?: ViewportScreenshotHelper.ResolutionSettings,
-    transparent?: boolean,
-    axes?: CameraHelperProps['axes']
 }
 
 export class DownloadScreenshotControls extends PluginUIComponent<{ close: () => void }, ImageControlsState> {
@@ -37,45 +27,6 @@ export class DownloadScreenshotControls extends PluginUIComponent<{ close: () =>
         isDisabled: false
     } as ImageControlsState
 
-    private canvasRef = React.createRef<HTMLCanvasElement>()
-    private updateQueue = new Subject();
-
-    private preview = () => {
-        if (!this.state.showPreview || !this.canvasRef.current || this.state.isDisabled) return;
-
-        const { canvas, width, height } = this.plugin.helpers.viewportScreenshot?.getPreview()!;
-        const ctx = this.canvasRef.current.getContext('2d');
-        if (!ctx) return;
-
-        const target = this.canvasRef.current;
-        const w = target.clientWidth;
-        const h = target.clientHeight;
-        target.width = w;
-        target.height = h;
-
-        ctx.clearRect(0, 0, w, h);
-        const frame = getViewportFrame(width, height, w, h);
-        if (this.plugin.helpers.viewportScreenshot?.values.transparent) {
-            this.drawCheckerboard(ctx, frame);
-        }
-        ctx.drawImage(canvas, frame.x, frame.y, frame.width, frame.height);
-    }
-
-    private drawCheckerboard(ctx: CanvasRenderingContext2D, frame: Viewport) {
-        // must be odd number!
-        const s = 13;
-        for (let i = 0; i < frame.width; i += s) {
-            for (let j = 0; j < frame.height; j += s) {
-                ctx.fillStyle = (i + j) % 2 ? '#ffffff' : '#bfbfbf';
-
-                const x = frame.x + i, y = frame.y + j;
-                const w = i + s > frame.width ? frame.width - i : s;
-                const h = j + s > frame.height ? frame.height - j : s;
-                ctx.fillRect(x, y, w, h);
-            }
-        }
-    }
-
     private download = () => {
         this.plugin.helpers.viewportScreenshot?.download();
         this.props.close();
@@ -91,42 +42,9 @@ export class DownloadScreenshotControls extends PluginUIComponent<{ close: () =>
     }
 
     componentDidMount() {
-        if (!this.plugin.canvas3d) return;
-
-        this.subscribe(this.updateQueue.pipe(debounceTime(33)), () => this.preview());
-
-        this.subscribe(this.plugin.events.canvas3d.settingsUpdated, () => {
-            this.updateQueue.next();
-        });
-
-        this.subscribe(this.plugin.canvas3d.didDraw.pipe(debounceTime(150)), () => {
-            if (this.state.isDisabled) return;
-            this.updateQueue.next();
-        });
-
         this.subscribe(this.plugin.state.data.behaviors.isUpdating, v => {
             this.setState({ isDisabled: v });
-            if (!v) this.updateQueue.next();
         });
-
-        this.subscribe(this.plugin.helpers.viewportScreenshot!.behaviors.values, () => {
-            this.forceUpdate();
-            this.updateQueue.next();
-        });
-
-        this.preview();
-    }
-
-    private setValues = (p: ViewportScreenshotHelperParams) => {
-        this.plugin.helpers.viewportScreenshot!.behaviors.values.next(p);
-    }
-
-    downloadToFileJson = () => {
-        PluginCommands.State.Snapshots.DownloadToFile(this.plugin, { type: 'json' });
-    }
-
-    downloadToFileZip = () => {
-        PluginCommands.State.Snapshots.DownloadToFile(this.plugin, { type: 'zip' });
     }
 
     open = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -134,28 +52,20 @@ export class DownloadScreenshotControls extends PluginUIComponent<{ close: () =>
         PluginCommands.State.Snapshots.OpenFile(this.plugin, { file: e.target.files![0] });
     }
 
-    onCanvasContextMenu = (e: React.MouseEvent) => {
-        e.preventDefault();
-        e.stopPropagation();
-    };
-
     render() {
-        const values = this.plugin.helpers.viewportScreenshot!.values;
-
         return <div>
-            <div className='msp-image-preview'>
-                <div style={{ position: 'relative', width: '100%', height: '100%' }}>
-                    <canvas ref={this.canvasRef} onClick={this.onCanvasContextMenu} onContextMenu={this.onCanvasContextMenu} style={{ width: '100%', height: '180px' }}></canvas>
-                    <ViewportFrame plugin={this.plugin} canvasRef={this.canvasRef} />
+            {this.state.showPreview && <div className='msp-image-preview'>
+                <div style={{ height: '180px', width: '100%', position: 'relative' }}>
+                    <ScreenshotPreview plugin={this.plugin} />
                 </div>
                 <CropControls plugin={this.plugin} />
-            </div>
+            </div>}
             <div className='msp-flex-row'>
                 {/* TODO: figure out how to do copy/paste in Firefox */}
                 {!!(navigator.clipboard as any).write && <Button icon={CopySvg} onClick={this.copy} disabled={this.state.isDisabled}>Copy</Button>}
                 <Button icon={GetAppSvg} onClick={this.download} disabled={this.state.isDisabled}>Download</Button>
             </div>
-            <ParameterControls params={this.plugin.helpers.viewportScreenshot!.params} values={values} onChangeValues={this.setValues} isDisabled={this.state.isDisabled} />
+            <ScreenshotParams plugin={this.plugin} isDisabled={this.state.isDisabled} />
             <ExpandGroup header='State'>
                 <StateExportImportControls onAction={this.props.close} />
                 <ExpandGroup header='Save Options' initiallyExpanded={false} noOffset>
@@ -166,175 +76,30 @@ export class DownloadScreenshotControls extends PluginUIComponent<{ close: () =>
     }
 }
 
-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);
+function ScreenshotParams({ plugin, isDisabled }: { plugin: PluginContext, isDisabled: boolean }) {
+    const helper = plugin.helpers.viewportScreenshot!;
+    const values = useBehavior(helper.behaviors.values);
+
+    return <ParameterControls params={helper.params} values={values} onChangeValues={v => helper.behaviors.values.next(v)} isDisabled={isDisabled} />;
 }
 
 function CropControls({ plugin }: { plugin: PluginContext }) {
     const helper = plugin.helpers.viewportScreenshot;
-    const params = useBehavior(helper?.behaviors.values);
-    const crop = useBehavior(helper?.behaviors.relativeCrop);
+    const cropParams = useBehavior(helper?.behaviors.cropParams!);
+    useBehavior(helper?.behaviors.relativeCrop);
 
-    if (!params || !crop || !helper) return null;
+    if (!helper) return null;
 
-    return <div style={{ width: '100%', height: '24px' }}>
-        <ToggleButton icon={CropOrginalSvg} title='Auto-crop' inline isSelected={params.autoCrop}
-            style={{ background: 'transparent', float: 'left', width: 'auto',  height: '24px', lineHeight: '24px' }}
-            toggle={() => helper.behaviors.values.next({ ...params, autoCrop: !params.autoCrop })} label={'Auto-crop ' + (params.autoCrop ? 'On' : 'Off') } />
+    return <div style={{ width: '100%', height: '24px', marginTop: '8px' }}>
+        <ToggleButton icon={CropOrginalSvg} title='Auto-crop' inline isSelected={cropParams.auto}
+            style={{ background: 'transparent', float: 'left', width: 'auto', height: '24px', lineHeight: '24px' }}
+            toggle={() => helper.toggleAutocrop()} label={'Auto-crop ' + (cropParams.auto ? 'On' : 'Off')} />
 
-        {!params.autoCrop && <Button icon={CropSvg} title='Crop'
+        {!cropParams.auto && <Button icon={CropSvg} title='Crop'
             style={{ background: 'transparent', float: 'right', height: '24px', lineHeight: '24px', width: '24px', padding: '0' }}
             onClick={() => helper.autocrop()} />}
-        {!isFullFrame(crop) && <Button icon={CropFreeSvg} title='Reset Crop'
+        {!cropParams.auto && !helper.isFullFrame && <Button icon={CropFreeSvg} title='Reset Crop'
             style={{ background: 'transparent', float: 'right', height: '24px', lineHeight: '24px', width: '24px', padding: '0' }}
             onClick={() => helper.resetCrop()} />}
     </div>;
-}
-
-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);
-
-    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 + 1;
-    cropFrame.height = rectCrop.b - rectCrop.t + 1;
-
-    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);
-        window.addEventListener('mouseup', onEnd);
-        window.addEventListener('mousemove', onMove);
-    };
-
-    const onEnd = () => {
-        window.removeEventListener('mouseup', onEnd);
-        window.removeEventListener('mousemove', onMove);
-
-        const cropFrame = cropFrameRef.current;
-        if (params?.autoCrop) {
-            helper.behaviors.values.next({ ...params, autoCrop: false });
-        }
-        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 contextMenu = (e: React.MouseEvent) => {
-        e.preventDefault();
-        e.stopPropagation();
-    };
-
-    const d = 4;
-    const border = `3px solid rgba(255, 87, 45, 0.75)`;
-    const transparent = 'transparent';
-
-    return <>
-        <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} onContextMenu={contextMenu} />
-
-        <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} onContextMenu={contextMenu} />
-        <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} onContextMenu={contextMenu} />
-        <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} onContextMenu={contextMenu} />
-        <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} onContextMenu={contextMenu} />
-
-        <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} onContextMenu={contextMenu} />
-        <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} onContextMenu={contextMenu} />
-        <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} onContextMenu={contextMenu} />
-        <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} onContextMenu={contextMenu} />
-    </>;
-}
-
-function toRect(viewport: Viewport) {
-    return { l: viewport.x, t: viewport.y, r: viewport.x + viewport.width - 1, b: viewport.y + viewport.height - 1 };
-}
-
-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) };
-    }
 }

+ 23 - 5
src/mol-plugin/util/viewport-screenshot.ts

@@ -9,6 +9,7 @@ import { Viewport } from '../../mol-canvas3d/camera/util';
 import { CameraHelperParams } from '../../mol-canvas3d/helper/camera-helper';
 import { ImagePass } from '../../mol-canvas3d/passes/image';
 import { canvasToBlob } from '../../mol-canvas3d/util';
+import { equalEps } from '../../mol-math/linear-algebra/3d/common';
 import { PluginComponent } from '../../mol-plugin-state/component';
 import { PluginStateObject } from '../../mol-plugin-state/objects';
 import { StateSelection } from '../../mol-state';
@@ -52,7 +53,6 @@ class ViewportScreenshotHelper extends PluginComponent {
             }),
             transparent: PD.Boolean(false),
             axes: CameraHelperParams.axes,
-            autoCrop: PD.Boolean(true, { isHidden: true })
         };
     }
     private _params: ReturnType<ViewportScreenshotHelper['createParams']> = void 0 as any;
@@ -65,9 +65,9 @@ class ViewportScreenshotHelper extends PluginComponent {
         values: this.ev.behavior<ViewportScreenshotHelperParams>({
             transparent: this.params.transparent.defaultValue,
             axes: { name: 'off', params: {} },
-            resolution: this.params.resolution.defaultValue,
-            autoCrop: this.params.autoCrop.defaultValue
+            resolution: this.params.resolution.defaultValue
         }),
+        cropParams: this.ev.behavior<{ auto: boolean, relativePadding: number }>({ auto: true, relativePadding: 0.1 }),
         relativeCrop: this.ev.behavior<Viewport>({ x: 0, y: 0, width: 1, height: 1 })
     };
 
@@ -75,6 +75,10 @@ class ViewportScreenshotHelper extends PluginComponent {
         return this.behaviors.values.value;
     }
 
+    get cropParams() {
+        return this.behaviors.cropParams.value;
+    }
+
     get relativeCrop() {
         return this.behaviors.relativeCrop.value;
     }
@@ -148,7 +152,21 @@ class ViewportScreenshotHelper extends PluginComponent {
         this.behaviors.relativeCrop.next({ x: 0, y: 0, width: 1, height: 1 });
     }
 
-    autocrop(relativePadding = 0.1) {
+    toggleAutocrop() {
+        if (this.cropParams.auto) {
+            this.behaviors.cropParams.next({ ...this.cropParams, auto: false });
+            this.resetCrop();
+        } else {
+            this.behaviors.cropParams.next({ ...this.cropParams, auto: true });
+        }
+    }
+
+    get isFullFrame() {
+        const crop = this.relativeCrop;
+        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);
+    }
+
+    autocrop(relativePadding = this.cropParams.relativePadding) {
         const { data, width, height } = this.previewData.image;
         const bgColor = this.previewData.transparent ? this.previewData.background : 0xff000000 | this.previewData.background;
 
@@ -231,7 +249,7 @@ class ViewportScreenshotHelper extends PluginComponent {
         const canvasCtx = canvas.getContext('2d');
         if (!canvasCtx) throw new Error('Could not create canvas 2d context');
         canvasCtx.putImageData(imageData, 0, 0);
-        if (this.values.autoCrop) this.autocrop();
+        if (this.cropParams.auto) this.autocrop();
         return { canvas, width: w, height: h };
     }