screenshot.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. /**
  2. * Copyright (c) 2020 mol* contributors, licensed under MIT, See LICENSE file for more info.
  3. *
  4. * @author David Sehnal <david.sehnal@gmail.com>
  5. */
  6. import * as React from 'react';
  7. import { useEffect, useLayoutEffect, useRef, useState } from 'react';
  8. import { Observable, Subject, Subscription } from 'rxjs';
  9. import { debounceTime } from 'rxjs/operators';
  10. import { Viewport } from '../../mol-canvas3d/camera/util';
  11. import { PluginContext } from '../../mol-plugin/context';
  12. import { ViewportScreenshotHelper } from '../../mol-plugin/util/viewport-screenshot';
  13. import { shallowEqual } from '../../mol-util/object';
  14. import { useBehavior } from '../hooks/use-behavior';
  15. export interface ScreenshotPreviewProps {
  16. plugin: PluginContext,
  17. suspend?: boolean,
  18. cropFrameColor?: string,
  19. borderColor?: string,
  20. borderWidth?: number,
  21. customBackground?: string
  22. }
  23. const _ScreenshotPreview = (props: ScreenshotPreviewProps) => {
  24. const { plugin, cropFrameColor } = props;
  25. const helper = plugin.helpers.viewportScreenshot!;
  26. const [currentCanvas, setCurrentCanvas] = useState<HTMLCanvasElement | null>(null);
  27. const canvasRef = useRef<HTMLCanvasElement | null>(null);
  28. const propsRef = useRef(props);
  29. useEffect(() => {
  30. propsRef.current = props;
  31. }, Object.values(props));
  32. useEffect(() => {
  33. if (currentCanvas !== canvasRef.current) {
  34. setCurrentCanvas(canvasRef.current);
  35. }
  36. });
  37. useEffect(() => {
  38. let paused = false;
  39. const updateQueue = new Subject();
  40. const subs: Subscription[] = [];
  41. function subscribe<T>(xs: Observable<T> | undefined, f: (v: T) => any) {
  42. if (!xs) return;
  43. subs.push(xs.subscribe(f));
  44. }
  45. function preview() {
  46. const p = propsRef.current;
  47. if (!p.suspend && !paused && canvasRef.current) {
  48. drawPreview(helper, canvasRef.current, p.customBackground, p.borderColor, p.borderWidth);
  49. }
  50. if (!canvasRef.current) updateQueue.next();
  51. }
  52. subscribe(updateQueue.pipe(debounceTime(33)), preview);
  53. subscribe(plugin.events.canvas3d.settingsUpdated, () => updateQueue.next());
  54. subscribe(plugin.canvas3d?.didDraw.pipe(debounceTime(150)), () => {
  55. if (paused) return;
  56. updateQueue.next();
  57. });
  58. subscribe(plugin.state.data.behaviors.isUpdating, v => {
  59. paused = v;
  60. if (!v) updateQueue.next();
  61. });
  62. subscribe(helper.behaviors.values, () => updateQueue.next());
  63. subscribe(helper.behaviors.cropParams, () => updateQueue.next());
  64. let resizeObserver: any = void 0;
  65. if (typeof ResizeObserver !== 'undefined') {
  66. resizeObserver = new ResizeObserver(() => updateQueue.next());
  67. }
  68. const canvas = canvasRef.current;
  69. resizeObserver?.observe(canvas);
  70. preview();
  71. return () => {
  72. subs.forEach(s => s.unsubscribe());
  73. resizeObserver?.unobserve(canvas);
  74. };
  75. }, [helper]);
  76. useLayoutEffect(() => {
  77. if (canvasRef.current) {
  78. drawPreview(helper, canvasRef.current, props.customBackground, props.borderColor, props.borderWidth);
  79. }
  80. }, [...Object.values(props)]);
  81. return <>
  82. <div style={{ position: 'relative', width: '100%', height: '100%' }}>
  83. <canvas ref={canvasRef} onContextMenu={e => { e.preventDefault(); e.stopPropagation(); }} style={{ display: 'block', width: '100%', height: '100%' }}></canvas>
  84. <ViewportFrame plugin={plugin} canvas={currentCanvas} color={cropFrameColor} />
  85. </div>
  86. </>;
  87. };
  88. export const ScreenshotPreview = React.memo(_ScreenshotPreview, (prev, next) => shallowEqual(prev, next));
  89. declare const ResizeObserver: any;
  90. function drawPreview(helper: ViewportScreenshotHelper, target: HTMLCanvasElement, customBackground?: string, borderColor?: string, borderWidth?: number) {
  91. const { canvas, width, height } = helper.getPreview()!;
  92. const ctx = target.getContext('2d');
  93. if (!ctx) return;
  94. const w = target.clientWidth;
  95. const h = target.clientHeight;
  96. target.width = w;
  97. target.height = h;
  98. ctx.clearRect(0, 0, w, h);
  99. const frame = getViewportFrame(width, height, w, h);
  100. if (customBackground) {
  101. ctx.fillStyle = customBackground;
  102. ctx.fillRect(frame.x, frame.y, frame.width, frame.height);
  103. } else if (helper.values.transparent) {
  104. // must be an odd number
  105. const s = 13;
  106. for (let i = 0; i < frame.width; i += s) {
  107. for (let j = 0; j < frame.height; j += s) {
  108. ctx.fillStyle = (i + j) % 2 ? '#ffffff' : '#bfbfbf';
  109. const x = frame.x + i, y = frame.y + j;
  110. const w = i + s > frame.width ? frame.width - i : s;
  111. const h = j + s > frame.height ? frame.height - j : s;
  112. ctx.fillRect(x, y, w, h);
  113. }
  114. }
  115. }
  116. ctx.drawImage(canvas, frame.x, frame.y, frame.width, frame.height);
  117. if (borderColor && borderWidth) {
  118. const w = borderWidth;
  119. ctx.rect(frame.x, frame.y, frame.width, frame.height);
  120. ctx.rect(frame.x + w, frame.y + w, frame.width - 2 * w, frame.height - 2 * w);
  121. ctx.fillStyle = borderColor;
  122. ctx.fill('evenodd');
  123. }
  124. }
  125. function ViewportFrame({ plugin, canvas, color = 'rgba(255, 87, 45, 0.75)' }: { plugin: PluginContext, canvas: HTMLCanvasElement | null, color?: string }) {
  126. const helper = plugin.helpers.viewportScreenshot;
  127. const params = useBehavior(helper?.behaviors.values!);
  128. const cropParams = useBehavior(helper?.behaviors.cropParams!);
  129. const crop = useBehavior(helper?.behaviors.relativeCrop!);
  130. const cropFrameRef = useRef<Viewport>({ x: 0, y: 0, width: 0, height: 0 });
  131. useBehavior(params?.resolution.name === 'viewport' ? plugin.canvas3d?.resized : void 0);
  132. const [drag, setDrag] = React.useState<string>('');
  133. const [start, setStart] = useState([0, 0]);
  134. const [current, setCurrent] = useState([0, 0]);
  135. if (!helper || !canvas) return null;
  136. const { width, height } = helper.getSizeAndViewport();
  137. const frame = getViewportFrame(width, height, canvas.clientWidth, canvas.clientHeight);
  138. const cropFrame: Viewport = {
  139. x: frame.x + Math.floor(frame.width * crop.x),
  140. y: frame.y + Math.floor(frame.height * crop.y),
  141. width: Math.ceil(frame.width * crop.width),
  142. height: Math.ceil(frame.height * crop.height)
  143. };
  144. const rectCrop = toRect(cropFrame);
  145. const rectFrame = toRect(frame);
  146. if (drag === 'move') {
  147. rectCrop.l += current[0] - start[0];
  148. rectCrop.r += current[0] - start[0];
  149. rectCrop.t += current[1] - start[1];
  150. rectCrop.b += current[1] - start[1];
  151. } else if (drag) {
  152. if (drag.indexOf('left') >= 0) {
  153. rectCrop.l += current[0] - start[0];
  154. } else if (drag.indexOf('right') >= 0) {
  155. rectCrop.r += current[0] - start[0];
  156. }
  157. if (drag.indexOf('top') >= 0) {
  158. rectCrop.t += current[1] - start[1];
  159. } else if (drag.indexOf('bottom') >= 0) {
  160. rectCrop.b += current[1] - start[1];
  161. }
  162. }
  163. if (rectCrop.l > rectCrop.r) {
  164. const t = rectCrop.l;
  165. rectCrop.l = rectCrop.r;
  166. rectCrop.r = t;
  167. }
  168. if (rectCrop.t > rectCrop.b) {
  169. const t = rectCrop.t;
  170. rectCrop.t = rectCrop.b;
  171. rectCrop.b = t;
  172. }
  173. const pad = 40;
  174. rectCrop.l = Math.min(rectFrame.r - pad, Math.max(rectFrame.l, rectCrop.l));
  175. rectCrop.r = Math.max(rectFrame.l + pad, Math.min(rectFrame.r, rectCrop.r));
  176. rectCrop.t = Math.min(rectFrame.b - pad, Math.max(rectFrame.t, rectCrop.t));
  177. rectCrop.b = Math.max(rectFrame.t + pad, Math.min(rectFrame.b, rectCrop.b));
  178. cropFrame.x = rectCrop.l;
  179. cropFrame.y = rectCrop.t;
  180. cropFrame.width = rectCrop.r - rectCrop.l + 1;
  181. cropFrame.height = rectCrop.b - rectCrop.t + 1;
  182. cropFrameRef.current = cropFrame;
  183. const onMove = (e: MouseEvent) => {
  184. e.preventDefault();
  185. setCurrent([e.pageX, e.pageY]);
  186. };
  187. const onTouchMove = (e: TouchEvent) => {
  188. e.preventDefault();
  189. const t = e.touches[0];
  190. setCurrent([t.pageX, t.pageY]);
  191. };
  192. const onTouchStart = (e: React.TouchEvent) => {
  193. e.preventDefault();
  194. setDrag(e.currentTarget.getAttribute('data-drag')! as any);
  195. const t = e.touches[0];
  196. const p = [t.pageX, t.pageY];
  197. setStart(p);
  198. setCurrent(p);
  199. window.addEventListener('touchend', onTouchEnd);
  200. window.addEventListener('touchmove', onTouchMove);
  201. };
  202. const onStart = (e: React.MouseEvent<HTMLElement>) => {
  203. e.preventDefault();
  204. setDrag(e.currentTarget.getAttribute('data-drag')! as any);
  205. const p = [e.pageX, e.pageY];
  206. setStart(p);
  207. setCurrent(p);
  208. window.addEventListener('mouseup', onEnd);
  209. window.addEventListener('mousemove', onMove);
  210. };
  211. const onEnd = () => {
  212. window.removeEventListener('mouseup', onEnd);
  213. window.removeEventListener('mousemove', onMove);
  214. finish();
  215. };
  216. const onTouchEnd = () => {
  217. window.removeEventListener('touchend', onTouchEnd);
  218. window.removeEventListener('touchmove', onTouchMove);
  219. finish();
  220. };
  221. function finish() {
  222. const cropFrame = cropFrameRef.current;
  223. if (cropParams.auto) {
  224. helper?.behaviors.cropParams.next({ ...cropParams, auto: false });
  225. }
  226. helper?.behaviors.relativeCrop.next({
  227. x: (cropFrame.x - frame.x) / frame.width,
  228. y: (cropFrame.y - frame.y) / frame.height,
  229. width: cropFrame.width / frame.width,
  230. height: cropFrame.height / frame.height
  231. });
  232. setDrag('');
  233. const p = [0, 0];
  234. setStart(p);
  235. setCurrent(p);
  236. }
  237. const contextMenu = (e: React.MouseEvent) => {
  238. e.preventDefault();
  239. e.stopPropagation();
  240. };
  241. const d = 4;
  242. const border = `3px solid ${color}`;
  243. const transparent = 'transparent';
  244. return <>
  245. <div data-drag='move' style={{ position: 'absolute', left: cropFrame.x, top: cropFrame.y, width: cropFrame.width, height: cropFrame.height, border, cursor: 'move' }} onMouseDown={onStart} onTouchStart={onTouchStart} draggable={false} onContextMenu={contextMenu} />
  246. <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} onTouchStart={onTouchStart} draggable={false} onContextMenu={contextMenu} />
  247. <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} onTouchStart={onTouchStart} draggable={false} onContextMenu={contextMenu} />
  248. <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} onTouchStart={onTouchStart} draggable={false} onContextMenu={contextMenu} />
  249. <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} onTouchStart={onTouchStart} draggable={false} onContextMenu={contextMenu} />
  250. <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} onTouchStart={onTouchStart} draggable={false} onContextMenu={contextMenu} />
  251. <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} onTouchStart={onTouchStart} draggable={false} onContextMenu={contextMenu} />
  252. <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} onTouchStart={onTouchStart} draggable={false} onContextMenu={contextMenu} />
  253. <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} onTouchStart={onTouchStart} draggable={false} onContextMenu={contextMenu} />
  254. </>;
  255. }
  256. function toRect(viewport: Viewport) {
  257. return { l: viewport.x, t: viewport.y, r: viewport.x + viewport.width - 1, b: viewport.y + viewport.height - 1 };
  258. }
  259. function getViewportFrame(srcWidth: number, srcHeight: number, w: number, h: number): Viewport {
  260. const a0 = srcWidth / srcHeight;
  261. const a1 = w / h;
  262. if (a0 <= a1) {
  263. const t = h * a0;
  264. return { x: Math.round((w - t) / 2), y: 0, width: Math.round(t), height: h };
  265. } else {
  266. const t = w / a0;
  267. return { x: 0, y: Math.round((h - t) / 2), width: w, height: Math.round(t) };
  268. }
  269. }